From 6d7d3299792aa5d178a107cd47ab84fdd14c5f29 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 2 Jul 2026 14:52:41 +0000 Subject: [PATCH] feat: add AI content ops flow --- ai_threads_auto_account_ops.md | 1678 +++++++++++++++++ backend/generate/api/copy_mission.api | 24 +- backend/generate/api/persona.api | 389 +++- backend/internal/bootstrap/init.go | 43 + .../create_copy_mission_handler.go | 3 +- .../delete_copy_mission_handler.go | 3 +- .../generate_copy_mission_matrix_handler.go | 3 +- .../copy_mission/get_copy_mission_handler.go | 3 +- .../get_copy_mission_scan_schedule_handler.go | 3 +- .../inspire_copy_mission_handler.go | 6 +- .../list_copy_mission_copy_drafts_handler.go | 3 +- .../list_copy_mission_scan_posts_handler.go | 3 +- .../list_copy_missions_handler.go | 3 +- .../start_copy_mission_analyze_job_handler.go | 3 +- ...art_copy_mission_copy_draft_job_handler.go | 6 +- .../start_copy_mission_matrix_job_handler.go | 6 +- .../start_copy_mission_scan_job_handler.go | 3 +- .../update_copy_mission_handler.go | 3 +- ...sert_copy_mission_scan_schedule_handler.go | 3 +- .../persona/create_content_plan_handler.go | 32 + .../persona/create_feedback_event_handler.go | 32 + .../persona/create_formula_pool_handler.go | 32 + .../create_knowledge_source_handler.go | 32 + .../generate_from_content_formula_handler.go | 2 +- .../generate_persona_topic_matrix_handler.go | 29 + .../persona/list_content_plans_handler.go | 32 + .../persona/list_formula_pools_handler.go | 32 + .../persona/list_knowledge_chunks_handler.go | 32 + .../persona/list_knowledge_sources_handler.go | 32 + .../list_persona_content_inbox_handler.go | 2 +- .../persona/list_topic_candidates_handler.go | 32 + .../prune_persona_copy_drafts_handler.go | 30 + .../schedule_persona_copy_draft_handler.go | 2 +- ...start_persona_formula_draft_job_handler.go | 30 + ...start_persona_rewrite_draft_job_handler.go | 32 + ...ersona_style_analysis_from_text_handler.go | 33 + .../start_persona_topic_matrix_job_handler.go | 30 + .../persona/update_content_plan_handler.go | 32 + backend/internal/handler/routes.go | 80 + backend/internal/library/copyvoice/human.go | 50 + .../internal/library/copyvoice/human_test.go | 21 + .../library/formula/analyze_pasted.go | 24 +- .../library/formula/generate_from_formula.go | 90 +- .../formula/generate_from_formula_test.go | 14 + backend/internal/library/formula/jsonflex.go | 95 + .../internal/library/formula/jsonflex_test.go | 22 + backend/internal/library/matrix/generate.go | 41 +- .../library/personacopy/formula_draft.go | 161 ++ .../library/personacopy/rewrite_draft.go | 139 ++ .../library/personacopy/topic_matrix.go | 321 ++++ .../internal/library/prompt/compose_test.go | 2 +- .../prompt/files/matrix_copy.system.md | 20 +- .../library/prompt/files/matrix_copy.user.md | 16 +- .../library/prompt/files/style8d.schema.md | 2 +- .../library/prompt/files/style8d.system.md | 26 +- backend/internal/library/style8d/analyze.go | 183 +- .../internal/library/style8d/analyze_test.go | 10 +- backend/internal/library/style8d/prompt.go | 50 +- .../internal/library/style8d/prompt_test.go | 12 +- backend/internal/library/style8d/runner.go | 41 + .../library/style8d/text_input_test.go | 73 + .../internal/library/threadspost/format.go | 35 + .../library/threadspost/format_test.go | 48 + backend/internal/library/threadspost/mimic.go | 65 + .../library/threadspost/mimic_test.go | 26 + .../internal/library/viral/analyze_viral.go | 67 +- .../library/viral/analyze_viral_test.go | 43 + backend/internal/library/viral/free_trends.go | 121 ++ .../internal/library/viral/mission_inspire.go | 119 +- .../library/viral/mission_inspire_collect.go | 24 + backend/internal/library/viral/replicate.go | 49 +- .../logic/copy_mission/capabilities.go | 16 - .../copy_mission/create_copy_mission_logic.go | 38 +- .../copy_mission/delete_copy_mission_logic.go | 12 +- ...delete_copy_mission_matrix_drafts_logic.go | 49 +- .../expand_copy_mission_graph_logic.go | 75 +- .../generate_copy_mission_matrix_logic.go | 221 +-- .../get_copy_mission_graph_logic.go | 24 +- .../copy_mission/get_copy_mission_logic.go | 19 +- .../get_copy_mission_scan_schedule_logic.go | 37 +- .../inspire_copy_mission_logic.go | 435 +++-- .../copy_mission/knowledge_graph_mapper.go | 67 - .../list_copy_mission_copy_drafts_logic.go | 35 +- .../list_copy_mission_scan_posts_logic.go | 44 +- .../copy_mission/list_copy_missions_logic.go | 22 +- backend/internal/logic/copy_mission/mapper.go | 300 --- .../logic/copy_mission/matrix_guard.go | 42 - ...atch_copy_mission_audience_sample_logic.go | 24 +- .../patch_copy_mission_graph_nodes_logic.go | 80 +- ...atch_copy_mission_similar_account_logic.go | 44 +- .../logic/copy_mission/research_map_entity.go | 78 - .../schedule_copy_mission_drafts_logic.go | 72 +- .../logic/copy_mission/schedule_helper.go | 49 - .../start_copy_mission_analyze_job_logic.go | 62 +- ...start_copy_mission_copy_draft_job_logic.go | 75 +- .../start_copy_mission_matrix_job_logic.go | 84 +- .../start_copy_mission_scan_job_logic.go | 90 +- .../copy_mission/update_copy_mission_logic.go | 48 +- ...upsert_copy_mission_scan_schedule_logic.go | 101 +- .../job/analyze_style8_d_from_worker_logic.go | 12 +- .../logic/persona/content_ops_mapper.go | 30 + .../logic/persona/copy_draft_mapper.go | 50 +- .../persona/create_content_plan_logic.go | 41 + .../persona/create_feedback_event_logic.go | 41 + .../persona/create_formula_pool_logic.go | 41 + .../persona/create_knowledge_source_logic.go | 41 + .../generate_from_content_formula_logic.go | 13 +- .../generate_persona_copy_draft_logic.go | 26 +- .../generate_persona_topic_matrix_logic.go | 65 + .../logic/persona/list_content_plans_logic.go | 43 + .../logic/persona/list_formula_pools_logic.go | 43 + .../persona/list_knowledge_chunks_logic.go | 43 + .../persona/list_knowledge_sources_logic.go | 43 + .../list_persona_content_inbox_logic.go | 16 +- .../persona/list_persona_copy_drafts_logic.go | 21 +- .../persona/list_topic_candidates_logic.go | 43 + .../prune_persona_copy_drafts_logic.go | 46 + .../publish_persona_copy_draft_logic.go | 22 +- .../schedule_persona_copy_draft_logic.go | 33 +- .../persona/schedule_persona_drafts_logic.go | 21 +- .../start_persona_formula_draft_job_logic.go | 83 + .../start_persona_rewrite_draft_job_logic.go | 87 + ..._persona_style_analysis_from_text_logic.go | 152 ++ .../start_persona_topic_matrix_job_logic.go | 80 + .../persona/update_content_plan_logic.go | 41 + .../update_persona_copy_draft_logic.go | 31 +- .../generate_own_post_formula_logic.go | 10 +- .../model/content_ops/domain/entity/entity.go | 112 ++ .../domain/repository/repository.go | 24 + .../content_ops/domain/usecase/usecase.go | 163 ++ .../model/content_ops/repository/mongo.go | 232 +++ .../model/content_ops/usecase/usecase.go | 415 ++++ .../model/copy_draft/domain/entity/draft.go | 54 +- .../domain/repository/repository.go | 2 + .../copy_draft/domain/usecase/usecase.go | 97 +- .../model/copy_draft/repository/mongo.go | 71 + .../model/copy_draft/usecase/usecase.go | 194 +- backend/internal/svc/job_workers.go | 62 + backend/internal/svc/service_context.go | 115 +- backend/internal/types/types.go | 422 ++++- .../worker/job/analyze_copy_mission.go | 390 ---- .../internal/worker/job/audience_samples.go | 26 - .../worker/job/expand_copy_mission_graph.go | 387 ---- .../worker/job/generate_copy_draft.go | 210 --- .../worker/job/generate_copy_matrix.go | 290 --- .../worker/job/generate_formula_draft.go | 81 + .../worker/job/generate_outreach_draft.go | 2 +- .../worker/job/generate_rewrite_draft.go | 82 + .../worker/job/generate_topic_matrix.go | 78 + .../internal/worker/job/payload_helpers.go | 89 + .../worker/job/refill_publish_inventory.go | 134 -- backend/internal/worker/job/scan_placement.go | 4 +- .../internal/worker/job/similar_accounts.go | 42 - backend/web/src/api/haixun.ts | 189 +- backend/web/src/components/JobMonitor.tsx | 218 ++- backend/web/src/components/ui.tsx | 3 +- backend/web/src/index.css | 69 + backend/web/src/lib/formulaCache.ts | 17 +- backend/web/src/lib/jobActions.ts | 26 + backend/web/src/lib/jobLabels.ts | 73 + backend/web/src/lib/jobStatus.ts | 4 +- backend/web/src/pages/AuthPage.tsx | 7 +- backend/web/src/pages/DashboardPage.tsx | 5 +- backend/web/src/pages/JobsPage.tsx | 342 +++- backend/web/src/pages/MissionsPage.tsx | 1614 +++++++++++----- backend/web/src/pages/PersonasPage.tsx | 154 +- backend/web/src/pages/PublishPage.tsx | 10 +- .../src/pages/missions/ContentInboxPanel.tsx | 208 -- .../pages/missions/FormulaWorkshopPanel.tsx | 282 --- .../src/pages/missions/PublishOpsPanel.tsx | 418 ---- backend/web/src/pages/publish/OwnPostsHub.tsx | 171 +- 171 files changed, 10518 insertions(+), 5319 deletions(-) create mode 100644 ai_threads_auto_account_ops.md create mode 100644 backend/internal/handler/persona/create_content_plan_handler.go create mode 100644 backend/internal/handler/persona/create_feedback_event_handler.go create mode 100644 backend/internal/handler/persona/create_formula_pool_handler.go create mode 100644 backend/internal/handler/persona/create_knowledge_source_handler.go create mode 100644 backend/internal/handler/persona/generate_persona_topic_matrix_handler.go create mode 100644 backend/internal/handler/persona/list_content_plans_handler.go create mode 100644 backend/internal/handler/persona/list_formula_pools_handler.go create mode 100644 backend/internal/handler/persona/list_knowledge_chunks_handler.go create mode 100644 backend/internal/handler/persona/list_knowledge_sources_handler.go create mode 100644 backend/internal/handler/persona/list_topic_candidates_handler.go create mode 100644 backend/internal/handler/persona/prune_persona_copy_drafts_handler.go create mode 100644 backend/internal/handler/persona/start_persona_formula_draft_job_handler.go create mode 100644 backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go create mode 100644 backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go create mode 100644 backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go create mode 100644 backend/internal/handler/persona/update_content_plan_handler.go create mode 100644 backend/internal/library/copyvoice/human.go create mode 100644 backend/internal/library/copyvoice/human_test.go create mode 100644 backend/internal/library/formula/generate_from_formula_test.go create mode 100644 backend/internal/library/formula/jsonflex.go create mode 100644 backend/internal/library/formula/jsonflex_test.go create mode 100644 backend/internal/library/personacopy/formula_draft.go create mode 100644 backend/internal/library/personacopy/rewrite_draft.go create mode 100644 backend/internal/library/personacopy/topic_matrix.go create mode 100644 backend/internal/library/style8d/runner.go create mode 100644 backend/internal/library/style8d/text_input_test.go create mode 100644 backend/internal/library/threadspost/format.go create mode 100644 backend/internal/library/threadspost/format_test.go create mode 100644 backend/internal/library/threadspost/mimic.go create mode 100644 backend/internal/library/threadspost/mimic_test.go create mode 100644 backend/internal/library/viral/analyze_viral_test.go create mode 100644 backend/internal/library/viral/free_trends.go delete mode 100644 backend/internal/logic/copy_mission/capabilities.go delete mode 100644 backend/internal/logic/copy_mission/knowledge_graph_mapper.go delete mode 100644 backend/internal/logic/copy_mission/mapper.go delete mode 100644 backend/internal/logic/copy_mission/matrix_guard.go delete mode 100644 backend/internal/logic/copy_mission/research_map_entity.go delete mode 100644 backend/internal/logic/copy_mission/schedule_helper.go create mode 100644 backend/internal/logic/persona/content_ops_mapper.go create mode 100644 backend/internal/logic/persona/create_content_plan_logic.go create mode 100644 backend/internal/logic/persona/create_feedback_event_logic.go create mode 100644 backend/internal/logic/persona/create_formula_pool_logic.go create mode 100644 backend/internal/logic/persona/create_knowledge_source_logic.go create mode 100644 backend/internal/logic/persona/generate_persona_topic_matrix_logic.go create mode 100644 backend/internal/logic/persona/list_content_plans_logic.go create mode 100644 backend/internal/logic/persona/list_formula_pools_logic.go create mode 100644 backend/internal/logic/persona/list_knowledge_chunks_logic.go create mode 100644 backend/internal/logic/persona/list_knowledge_sources_logic.go create mode 100644 backend/internal/logic/persona/list_topic_candidates_logic.go create mode 100644 backend/internal/logic/persona/prune_persona_copy_drafts_logic.go create mode 100644 backend/internal/logic/persona/start_persona_formula_draft_job_logic.go create mode 100644 backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go create mode 100644 backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go create mode 100644 backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go create mode 100644 backend/internal/logic/persona/update_content_plan_logic.go create mode 100644 backend/internal/model/content_ops/domain/entity/entity.go create mode 100644 backend/internal/model/content_ops/domain/repository/repository.go create mode 100644 backend/internal/model/content_ops/domain/usecase/usecase.go create mode 100644 backend/internal/model/content_ops/repository/mongo.go create mode 100644 backend/internal/model/content_ops/usecase/usecase.go create mode 100644 backend/internal/svc/job_workers.go delete mode 100644 backend/internal/worker/job/analyze_copy_mission.go delete mode 100644 backend/internal/worker/job/audience_samples.go delete mode 100644 backend/internal/worker/job/expand_copy_mission_graph.go delete mode 100644 backend/internal/worker/job/generate_copy_draft.go delete mode 100644 backend/internal/worker/job/generate_copy_matrix.go create mode 100644 backend/internal/worker/job/generate_formula_draft.go create mode 100644 backend/internal/worker/job/generate_rewrite_draft.go create mode 100644 backend/internal/worker/job/generate_topic_matrix.go create mode 100644 backend/internal/worker/job/payload_helpers.go delete mode 100644 backend/internal/worker/job/refill_publish_inventory.go delete mode 100644 backend/internal/worker/job/similar_accounts.go create mode 100644 backend/web/src/lib/jobActions.ts create mode 100644 backend/web/src/lib/jobLabels.ts delete mode 100644 backend/web/src/pages/missions/ContentInboxPanel.tsx delete mode 100644 backend/web/src/pages/missions/FormulaWorkshopPanel.tsx delete mode 100644 backend/web/src/pages/missions/PublishOpsPanel.tsx diff --git a/ai_threads_auto_account_ops.md b/ai_threads_auto_account_ops.md new file mode 100644 index 0000000..45d2a89 --- /dev/null +++ b/ai_threads_auto_account_ops.md @@ -0,0 +1,1678 @@ +# AI 自動經營 Threads 帳號系統規格書 + +> 目標:建立一套可以自動經營 Threads 帳號的 AI 系統。 +> 系統不是單純產文,而是能夠「找話題、判斷切角、生成內容、檢查品質、發布排程、回收成效、分析原因、更新策略」的閉環內容營運系統。 + +--- + +## 0. 核心概念 + +這套系統的核心不是「AI 幫我寫一篇文」,而是: + +```text +AI 自動經營 Threads 帳號 = +話題雷達 ++ 帳號大腦 ++ 內容公式池 ++ 切角推理 ++ 品質閘門 ++ 自動排程/發布 ++ 成效回收 ++ 策略自我更新 +``` + +系統要做到: + +1. 自己發現新話題。 +2. 自己判斷話題適不適合帳號。 +3. 自己選擇內容任務,例如漲粉、互動、收藏、信任、人設。 +4. 自己從公式池選擇適合的開頭、中段、結尾、CTA。 +5. 自己產出多版本草稿。 +6. 自己檢查是否太 AI、太公式化、太像競品、太高風險。 +7. 自己排程或進入人工審核。 +8. 自己回收發文後的成效。 +9. 自己分析哪裡有效、哪裡沒效。 +10. 自己更新下一輪發文策略。 + +--- + +## 1. 系統總流程 + +```mermaid +flowchart TD + A[資料來源收集] --> B[資料清洗與解析] + B --> C[帳號大腦 Brand Brain] + C --> D[話題雷達 Topic Radar] + D --> E[話題評分與分類] + E --> F[內容策略決策器] + F --> G[公式池與切角引擎] + G --> H[AI 產文工廠] + H --> I[品質閘門 Gatekeeper] + I --> J{是否可發布} + J -->|高信心| K[自動排程/發布] + J -->|中信心| L[人工審核] + J -->|低信心| M[退回重寫] + L --> K + M --> H + K --> N[成效回收] + N --> O[成效分析] + O --> P[策略自我更新] + P --> D +``` + +--- + +## 2. 系統模組拆分 + +```mermaid +flowchart LR + A[Ingestion Service] --> B[Knowledge Brain Service] + B --> C[Topic Radar Service] + C --> D[Strategy Planner] + D --> E[Formula Engine] + E --> F[Content Generator] + F --> G[Quality Gatekeeper] + G --> H[Scheduler & Publisher] + H --> I[Insight Collector] + I --> J[Learning Engine] + J --> B +``` + +### 2.1 Ingestion Service + +負責收集資料。 + +資料來源包含: + +| 來源 | 用途 | 優先級 | +|---|---|---:| +| 使用者上傳 Excel / Markdown / PDF | 建立帳號知識庫與風格資料 | 高 | +| 自己歷史貼文 | 學習帳號語氣、主題、成效 | 高 | +| Threads 關鍵字搜尋 | 發現新話題 | 高 | +| 指定競品帳號 | 拆解內容 DNA,不直接複製 | 高 | +| Brave Search / Google Search | 補充外部資料與查證 | 中 | +| RSS / 官方網站 | 專業帳號資料來源 | 中 | +| 手動輸入筆記 | 補充人設、想法、禁忌 | 中 | +| 留言與回覆 | 找粉絲真正關心的問題 | 高 | + +### 2.2 Knowledge Brain Service + +負責維護帳號大腦。 + +包含: + +1. Brand Memory:帳號設定、人設、語氣、禁用規則。 +2. Knowledge Memory:文件、主題、知識點、可引用資料。 +3. Style Memory:句型、開頭、結尾、CTA、語氣節奏。 +4. Formula Memory:不同任務對應的內容公式。 +5. Feedback Memory:成效、成功模式、失敗模式、人工評分。 + +### 2.3 Topic Radar Service + +負責找話題、去重、分類、評分。 + +### 2.4 Strategy Planner + +負責決定: + +1. 今天要發什麼主題。 +2. 這篇文的任務是什麼。 +3. 要用什麼切角。 +4. 要用什麼情緒槓桿。 +5. 要不要進人工審核。 + +### 2.5 Formula Engine + +負責從公式池中挑出: + +1. 開頭方式。 +2. 中段結構。 +3. 情緒槓桿。 +4. 結尾方式。 +5. CTA 類型。 + +### 2.6 Content Generator + +負責產出多版本草稿,並根據帳號語氣改寫。 + +### 2.7 Quality Gatekeeper + +負責檢查: + +1. AI 感。 +2. 公式感。 +3. 相似度。 +4. 事實風險。 +5. 醫療、金融、法律等高風險內容。 +6. 帳號一致性。 +7. 發文頻率與疲乏度。 + +### 2.8 Scheduler & Publisher + +負責排程與發布。 + +支援: + +1. Threads API 發布。 +2. Web Intent 半自動發文。 +3. 一鍵複製手動發文。 + +### 2.9 Insight Collector + +負責回收成效。 + +收集: + +1. impressions。 +2. likes。 +3. replies。 +4. reposts。 +5. quotes。 +6. follows gained。 +7. profile visits。 +8. reply sentiment。 +9. manual rating。 + +### 2.10 Learning Engine + +負責分析成效並更新策略。 + +--- + +## 3. 資料收集流程 + +```mermaid +flowchart TD + A[資料來源] --> B[Raw Data Pool] + B --> C[清洗 HTML / 雜訊] + C --> D[切 Chunk] + D --> E[抽取主題 Topic] + D --> F[抽取實體 Entity] + D --> G[抽取觀點 Claim] + D --> H[抽取語氣 Style] + D --> I[抽取公式 Pattern] + E --> J[Knowledge Brain] + F --> J + G --> J + H --> J + I --> J + J --> K[向量索引] + J --> L[關係資料] +``` + +### 3.1 匯入文件 + +支援格式: + +```text +.xlsx +.csv +.md +.txt +.pdf +.docx +.zip, for Obsidian vault +``` + +### 3.2 Excel 匯入邏輯 + +如果使用者上傳類似「小日子長大中.xlsx」這種內容檔案,可以這樣解析: + +| Sheet 類型 | 解析用途 | +|---|---| +| 發文 | 當作歷史貼文範例、語氣樣本、主題樣本 | +| 產圖 | 當作 IG 輪播與視覺規格 | +| 脆文 | 當作 Threads prompt 與內容風格樣本 | + +每一列轉成: + +```json +{ + "source_type": "excel", + "sheet_name": "發文", + "title": "備孕搜尋紀錄", + "content": "開始備孕後,Google 或 AI 可能比我本人更了解我的身體狀況...", + "topics": ["備孕", "焦慮", "搜尋", "共鳴"], + "content_type": "情緒共鳴文", + "style_tags": ["溫柔", "真實", "口語", "不焦慮推進"] +} +``` + +### 3.3 Obsidian 匯入邏輯 + +Obsidian 不要當核心資料庫,只當匯入與輸出入口。 + +解析內容: + +```text +frontmatter +# headings +[[internal links]] +#tags +tables +paragraphs +attachments references +``` + +轉成: + +```json +{ + "note_title": "第一次看生殖門診", + "tags": ["備孕", "生殖門診"], + "links": ["AMH", "FSH", "試管流程"], + "chunks": [], + "topics": [], + "claims": [], + "source_path": "/vault/備孕/第一次看生殖門診.md" +} +``` + +--- + +## 4. 帳號大腦 Brand Brain + +```mermaid +flowchart TD + A[帳號基本設定] --> F[Brand Brain] + B[歷史貼文] --> F + C[上傳文件] --> F + D[競品分析] --> F + E[成效回饋] --> F + F --> G[Brand Memory] + F --> H[Knowledge Memory] + F --> I[Style Memory] + F --> J[Formula Memory] + F --> K[Feedback Memory] +``` + +### 4.1 Brand Memory + +記住帳號是誰。 + +```json +{ + "account_name": "小日子長大中", + "persona": "拾日", + "positioning": "備孕、試管、孕育日常心境分享", + "tone": ["溫柔", "真實", "陪伴", "不焦慮推進"], + "avoid": ["太AI", "太醫療", "太雞湯", "保證效果", "過度正能量"], + "content_pillars": [ + "備孕心情", + "試管流程", + "生殖門診", + "伴侶陪伴", + "等待焦慮", + "幽默共鳴" + ], + "signature_lines": [ + "不是只有成功那天,這段路才算數", + "把這些小小的日子,一點一點拾起來" + ] +} +``` + +### 4.2 Knowledge Memory + +記住專業知識與資料來源。 + +```json +{ + "topic": "第一次看生殖門診", + "knowledge_points": [ + "可先整理月經週期", + "可準備過去檢查報告", + "可列出想問醫生的問題" + ], + "risk_level": "medium", + "requires_fact_check": true, + "source_ids": ["doc_001", "doc_002"] +} +``` + +### 4.3 Style Memory + +記住帳號怎麼說話。 + +```json +{ + "opening_patterns": ["其實我一直覺得", "有時候不是", "今天突然想到"], + "sentence_style": { + "short_sentence_ratio": 0.65, + "paragraph_spacing": "loose", + "emoji_usage": "low" + }, + "common_endings": ["今天先這樣也沒關係", "慢慢來也算有在往前"], + "avoid_phrases": ["在這個快節奏的時代", "總而言之", "讓我們一起"] +} +``` + +### 4.4 Formula Memory + +記住不同內容任務的公式。 + +```json +{ + "mission": "情緒共鳴文", + "formula": { + "opening": "真實情緒開場", + "body": "生活細節堆疊", + "emotion": "被理解感", + "ending": "溫柔但不雞湯", + "cta": "輕提問" + }, + "use_case": ["等待焦慮", "月經來", "看到別人懷孕"], + "avoid": ["不要寫成衛教文", "不要硬塞解方"] +} +``` + +### 4.5 Feedback Memory + +記住什麼有效。 + +```json +{ + "insight": "反差開場的 reply rate 高於帳號平均 42%", + "action": "提高反差開場權重", + "confidence": 0.78, + "applied_to_strategy_version": "v3" +} +``` + +--- + +## 5. 話題雷達 Topic Radar + +```mermaid +flowchart TD + A[每日定時掃描] --> B[收集候選話題] + B --> C[去重] + C --> D[分類] + D --> E[熱度分數] + D --> F[帳號適配分數] + D --> G[互動潛力] + D --> H[可延伸性] + D --> I[風險分數] + E --> J[Topic Score] + F --> J + G --> J + H --> J + I --> J + J --> K[候選題庫] +``` + +### 5.1 話題評分公式 + +```text +Topic Score = +熱度分數 * 0.25 ++ 帳號適配度 * 0.30 ++ 可延伸性 * 0.15 ++ 互動潛力 * 0.15 ++ 資料可信度 * 0.10 +- 風險分數 * 0.20 +``` + +### 5.2 話題分類 + +| 類型 | 用途 | 範例 | +|---|---|---| +| 熱點話題 | 跟上討論,提高曝光 | 最近大家在討論 AMH | +| 長尾知識 | 建立專業與收藏 | 第一次看生殖門診準備 | +| 情緒共鳴 | 增加轉發與留言 | 等待開獎真的很累 | +| 互動提問 | 養留言 | 大家第一次看診最怕什麼 | +| 人設生活 | 讓帳號像真人 | 今天只是好好吃飯 | +| 系列主題 | 長期內容資產 | 備孕小字典系列 | + +### 5.3 Candidate Topic Schema + +```json +{ + "id": "topic_001", + "account_id": "account_001", + "name": "第一次看生殖門診,可以準備什麼?", + "source": "keyword_search", + "category": "長尾知識", + "heat_score": 72, + "fit_score": 94, + "interaction_score": 68, + "extend_score": 85, + "risk_score": 35, + "final_score": 78.5, + "recommended_missions": ["收藏文", "互動文", "情緒共鳴文"] +} +``` + +--- + +## 6. 內容策略決策器 Strategy Planner + +系統每次產文前,要先決定「這篇文的任務」。 + +```mermaid +flowchart TD + A[候選話題] --> B[讀取帳號策略] + B --> C[讀取最近貼文] + C --> D[讀取成效記憶] + D --> E[決定內容任務] + E --> F[決定切角] + F --> G[產生 Content Plan] +``` + +### 6.1 內容任務分類 + +| 任務 | 目的 | 適合公式 | +|---|---|---| +| 漲粉文 | 擴散、追蹤 | 共鳴、反差、痛點、金句 | +| 互動文 | 引留言 | 提問、二選一、經驗募集 | +| 收藏文 | 提高保存 | 清單、步驟、懶人包 | +| 信任文 | 建立專業與真實感 | 經驗、觀察、案例 | +| 人設文 | 讓帳號有個性 | 生活、價值觀、吐槽 | +| 轉換文 | 引導私訊/連結/資源 | 模板、工具、服務 | +| 系列文 | 長期經營 | 連載、週更、固定單元 | + +### 6.2 Content Plan Schema + +```json +{ + "topic": "第一次看生殖門診", + "mission": "互動文", + "target_audience": "準備第一次去生殖門診的備孕女性", + "angle": "診間會腦袋空白,所以先問大家都準備什麼", + "opening_type": "具體問題開場", + "body_type": "列出 3 個自己想到但不確定的問題", + "emotion": "緊張但想準備好", + "ending_type": "邀請補充", + "cta_type": "經驗募集型", + "risk_level": "medium", + "requires_human_review": true, + "avoid": [ + "不要寫成衛教文", + "不要太完整,保留留言空間", + "不要溫柔金句收尾", + "不要使用最近 7 天重複用過的開頭" + ] +} +``` + +--- + +## 7. 公式池與切角引擎 Formula Engine + +```mermaid +flowchart TD + A[Content Plan] --> B[選擇開頭公式] + A --> C[選擇中段結構] + A --> D[選擇情緒槓桿] + A --> E[選擇結尾方式] + A --> F[選擇 CTA] + B --> G[組成產文指令] + C --> G + D --> G + E --> G + F --> G +``` + +### 7.1 開頭公式池 + +| 類型 | 用途 | 範例 | +|---|---|---| +| 情緒直球 | 快速共鳴 | 其實我一直覺得,備孕最累的是等待。 | +| 反差開場 | 提高停留 | 以前以為看診最難,後來發現最難的是不知道要問什麼。 | +| 場景開場 | 增加真人感 | 今天在診間等叫號的時候,腦袋突然一片空白。 | +| 衝突觀點 | 建立觀點 | 我不太喜歡別人說「放輕鬆就好」。 | +| 問題開場 | 引互動 | 第一次看生殖門診,大家最怕的是什麼? | +| 自嘲開場 | 增加親近感 | 備孕後,我的 Google 搜尋紀錄比我本人還像醫生。 | + +### 7.2 中段結構池 + +| 類型 | 用途 | +|---|---| +| 生活細節堆疊 | 情緒共鳴文 | +| 三點整理 | 收藏文、知識文 | +| 自問自答 | 觀點文、焦慮文 | +| 故事推進 | 人設文、信任文 | +| 對比以前和現在 | 成長型內容 | +| 錯誤觀念澄清 | 專業文 | +| 故意不講滿 | 互動文 | + +### 7.3 情緒槓桿池 + +| 情緒 | 適合內容 | +|---|---| +| 被理解感 | 共鳴文 | +| 小小委屈 | 備孕、職場、生活 | +| 無奈但不崩潰 | Threads 短文 | +| 自嘲 | 反差、幽默 | +| 溫柔補償 | 陪伴帳號 | +| 不想說破的疲憊 | 深夜文 | +| 終於有人講出來 | 轉發型內容 | + +### 7.4 結尾公式池 + +| 類型 | 用途 | +|---|---| +| 溫柔陪伴 | 情緒文 | +| 反諷補刀 | 幽默文 | +| 開放提問 | 互動文 | +| 收藏提醒 | 知識文 | +| 留白不收 | 日記文 | +| 系列預告 | 系列內容 | + +### 7.5 CTA 公式池 + +| CTA 類型 | 範例 | +|---|---| +| 共鳴型 | 你也有這種感覺嗎? | +| 經驗募集型 | 第一次看診你最慶幸自己有準備什麼? | +| 補充型 | 有漏掉的也歡迎補充。 | +| 收藏型 | 可以先存起來,下次看診前翻一下。 | +| 轉發型 | 轉給那個一直叫你放輕鬆的人。 | +| 無 CTA | 讓文章自然停在情緒裡。 | + +--- + +## 8. 防膩系統 Anti-Fatigue Engine + +避免每篇都長一樣。 + +```mermaid +flowchart TD + A[新草稿] --> B[讀取最近 7/14/30 天貼文] + B --> C[檢查開頭重複] + B --> D[檢查結尾重複] + B --> E[檢查 CTA 重複] + B --> F[檢查任務比例] + B --> G[檢查句型相似度] + B --> H[計算膩感分數] + H --> I{是否過高} + I -->|是| J[退回重寫] + I -->|否| K[通過] +``` + +### 8.1 防重複規則 + +| 檢查項目 | 規則 | +|---|---| +| 開頭類型 | 連續 3 篇不能同類型 | +| 結尾類型 | 連續 3 篇不能都是溫柔金句 | +| CTA | 不能每篇都「你也會這樣嗎」 | +| 文章長度 | 長短文要交錯 | +| 情緒走向 | 不能每天都是焦慮到安慰 | +| 內容任務 | 不能連續 5 篇都是共鳴文 | +| 句型相似度 | 超過閾值退回重寫 | +| 主題密度 | 同一主題設定冷卻期 | + +### 8.2 Fatigue Score + +```text +Fatigue Score = +Opening Similarity * 0.20 ++ Ending Similarity * 0.20 ++ CTA Similarity * 0.15 ++ Structure Similarity * 0.25 ++ Topic Repetition * 0.20 +``` + +建議門檻: + +```text +0 - 40:安全 +41 - 70:需要微調 +71 - 100:退回重寫 +``` + +--- + +## 9. AI 產文工廠 Content Generator + +```mermaid +flowchart TD + A[Content Plan] --> B[生成初稿 A] + A --> C[生成初稿 B] + A --> D[生成初稿 C] + B --> E[帳號語氣改寫] + C --> E + D --> E + E --> F[真人感修稿] + F --> G[Threads 格式優化] + G --> H[版本評分] + H --> I[選出最佳版本] +``` + +### 9.1 生成版本 + +每次至少產 3 個版本: + +| 版本 | 目的 | +|---|---| +| A 版 | 穩定帳號風格 | +| B 版 | 更有共鳴 | +| C 版 | 更容易互動 | +| D 版 optional | 更短、更 Threads | +| E 版 optional | 更幽默、更反差 | + +### 9.2 產文指令格式 + +```text +請產出一篇 Threads 文。 + +帳號:小日子長大中 +人設:拾日 +語氣:溫柔、真實、陪伴、不焦慮推進 +主題:第一次看生殖門診 +內容任務:互動文 +切角:診間會腦袋空白,所以先問大家都準備什麼 +開頭方式:具體問題開場 +中段方式:列出 3 個自己想到但不確定的問題 +情緒:緊張但想準備好 +結尾方式:邀請補充 +CTA:經驗募集型 + +禁止: +- 不要寫成衛教文 +- 不要太像 AI +- 不要使用「在這個快節奏的時代」 +- 不要溫柔金句收尾 +- 不要超過 500 字 +- 不要直接給醫療建議 +``` + +--- + +## 10. 品質閘門 Quality Gatekeeper + +```mermaid +flowchart TD + A[草稿] --> B[AI 感檢查] + A --> C[公式感檢查] + A --> D[競品相似度檢查] + A --> E[事實查證] + A --> F[風險檢查] + A --> G[帳號一致性檢查] + A --> H[發布頻率檢查] + B --> I[總分] + C --> I + D --> I + E --> I + F --> I + G --> I + H --> I + I --> J{決策} + J -->|高分| K[可自動排程] + J -->|中分| L[人工審核] + J -->|低分| M[退回重寫] +``` + +### 10.1 檢查分數 + +| 分數 | 說明 | +|---|---| +| ai_score | 越高代表越像 AI,需降低 | +| formula_score | 越高代表公式感越重 | +| brand_fit_score | 越高代表越符合帳號 | +| risk_score | 越高代表越危險 | +| similarity_score | 越高代表越像競品或過去貼文 | +| engagement_potential | 預測互動潛力 | +| freshness_score | 新鮮感 | + +### 10.2 發布決策 + +```text +總分 >= 85 且 risk_score < 30:可自動排程 +總分 70 - 84:進人工審核 +總分 < 70:退回重寫 +risk_score >= 60:強制人工審核 +similarity_score >= 75:退回重寫 +ai_score >= 70:退回真人感修稿 +``` + +### 10.3 高風險內容 + +以下內容預設不能全自動發布: + +1. 醫療建議。 +2. 金融投資建議。 +3. 法律判斷。 +4. 政治爭議。 +5. 指控個人或公司。 +6. 可能造成恐慌的資訊。 +7. 未查證的數據。 + +--- + +## 11. 排程與發布 Scheduler & Publisher + +```mermaid +flowchart TD + A[通過品質閘門] --> B[選擇發布時間] + B --> C[檢查頻率限制] + C --> D[建立排程] + D --> E{發布方式} + E -->|API| F[Threads API 發布] + E -->|Web Intent| G[開啟半自動發文] + E -->|Manual| H[一鍵複製] + F --> I[記錄 post_id] + G --> I + H --> I + I --> J[等待成效回收] +``` + +### 11.1 發布策略 + +| 模式 | 用途 | +|---|---| +| API 自動發 | 權限完整、低風險內容 | +| Web Intent | API 權限不足時 | +| 一鍵複製 | 初期 MVP 或人工操作 | + +### 11.2 排程策略 + +系統需要根據過去成效找出最佳時間。 + +第一版可先用簡單設定: + +```json +{ + "posting_windows": ["12:00-13:30", "20:30-23:00"], + "max_posts_per_day": 3, + "min_gap_minutes": 180, + "high_risk_requires_review": true +} +``` + +--- + +## 12. 成效回收 Insight Collector + +```mermaid +flowchart TD + A[已發布貼文] --> B[1 小時成效] + A --> C[6 小時成效] + A --> D[24 小時成效] + A --> E[72 小時成效] + B --> F[Performance DB] + C --> F + D --> F + E --> F + F --> G[成效分析器] +``` + +### 12.1 回收時間點 + +| 時間 | 用途 | +|---|---| +| 1 小時 | 初速判斷 | +| 6 小時 | 當日表現 | +| 24 小時 | 主要成效 | +| 72 小時 | 長尾成效 | +| 7 天 optional | 長期資料 | + +### 12.2 指標 + +```json +{ + "post_id": "post_001", + "collected_at": "2026-07-02T21:00:00+08:00", + "impressions": 12000, + "likes": 580, + "replies": 72, + "reposts": 31, + "quotes": 8, + "follows_gained": 45, + "profile_visits": 320, + "engagement_rate": 0.057, + "reply_sentiment": "positive" +} +``` + +--- + +## 13. 成效分析 Performance Analyzer + +```mermaid +flowchart TD + A[Performance DB] --> B[按主題分析] + A --> C[按公式分析] + A --> D[按開頭分析] + A --> E[按 CTA 分析] + A --> F[按情緒分析] + A --> G[按發布時間分析] + B --> H[洞察] + C --> H + D --> H + E --> H + F --> H + G --> H +``` + +### 13.1 分析問題 + +系統每次分析要回答: + +1. 哪個主題表現最好? +2. 哪種開頭最容易讓人回覆? +3. 哪種 CTA 真的有效? +4. 哪種情緒最容易被轉發? +5. 哪種內容最容易漲粉? +6. 哪種內容收藏價值最高? +7. 哪個發文時間效果最好? +8. 哪些內容應該減少? +9. 哪些內容可以延伸成系列? + +### 13.2 分析結果範例 + +```json +{ + "account_id": "account_001", + "period": "last_14_days", + "insights": [ + { + "type": "opening_pattern", + "finding": "反差開場的平均 reply rate 比帳號平均高 42%", + "recommendation": "下週提高反差開場權重,但避免連續使用", + "confidence": 0.82 + }, + { + "type": "content_mission", + "finding": "收藏文的 follows gained 較低,但 profile visits 較高", + "recommendation": "收藏文結尾加入更明確的人設介紹或系列預告", + "confidence": 0.74 + } + ] +} +``` + +--- + +## 14. 策略自我更新 Learning Engine + +```mermaid +flowchart TD + A[成效分析] --> B[更新主題權重] + A --> C[更新公式權重] + A --> D[更新 CTA 權重] + A --> E[更新發布時間] + A --> F[更新禁用規則] + A --> G[更新內容比例] + B --> H[Strategy Version] + C --> H + D --> H + E --> H + F --> H + G --> H + H --> I[下一輪產文使用] +``` + +### 14.1 策略權重更新 + +第一版不要 fine-tune,先更新權重。 + +```json +{ + "strategy_version": "v3", + "content_mix": { + "emotion_posts": 30, + "knowledge_posts": 30, + "interaction_posts": 20, + "life_posts": 10, + "conversion_posts": 10 + }, + "opening_weights": { + "反差開場": 35, + "情緒直球": 25, + "問題開場": 20, + "場景開場": 15, + "金句開場": 5 + }, + "cta_weights": { + "經驗募集型": 35, + "收藏型": 25, + "共鳴型": 20, + "轉發型": 10, + "無CTA": 10 + } +} +``` + +### 14.2 策略版本化 + +每次更新都要記錄原因。 + +```json +{ + "from_version": "v2", + "to_version": "v3", + "changes": [ + { + "field": "content_mix.interaction_posts", + "from": 15, + "to": 20, + "reason": "過去 14 天互動文 reply rate 高於帳號平均 38%" + }, + { + "field": "opening_weights.金句開場", + "from": 15, + "to": 5, + "reason": "金句開場連續三週互動下降,且 AI 感評分偏高" + } + ] +} +``` + +--- + +## 15. Dashboard 設計 + +首頁應該像「AI 經營戰情室」。 + +### 15.1 首頁區塊 + +```text +今日建議發文 +- 推薦主題 +- 推薦任務 +- 推薦切角 +- 推薦時間 +- 風險提醒 + +草稿工廠 +- 待審核 +- 可排程 +- 需重寫 +- 已發布 + +昨日成效 +- 最佳貼文 +- 最差貼文 +- 主要原因 +- 下一步建議 + +策略更新 +- 本週提高什麼比例 +- 本週降低什麼比例 +- 新增禁用規則 +``` + +### 15.2 單篇貼文分析頁 + +```text +貼文內容 +內容任務:互動文 +主題:第一次看生殖門診 +開頭類型:問題開場 +中段類型:故意不講滿 +CTA:經驗募集型 + +成效: +- impressions +- likes +- replies +- reposts +- follows gained + +AI 分析: +- 為什麼有效 +- 哪裡可以更好 +- 是否適合延伸成系列 +- 下次建議用什麼變奏 +``` + +--- + +## 16. DB Schema 草案 + +### 16.1 accounts + +```sql +CREATE TABLE accounts ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + platform TEXT NOT NULL DEFAULT 'threads', + persona TEXT, + positioning TEXT, + tone_config JSONB, + avoid_config JSONB, + content_pillars JSONB, + status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.2 sources + +```sql +CREATE TABLE sources ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + type TEXT NOT NULL, + title TEXT, + url TEXT, + file_path TEXT, + raw_content TEXT, + parsed_status TEXT DEFAULT 'pending', + metadata JSONB, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.3 knowledge_chunks + +```sql +CREATE TABLE knowledge_chunks ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + source_id UUID REFERENCES sources(id), + content TEXT NOT NULL, + topics JSONB, + entities JSONB, + claims JSONB, + style_tags JSONB, + risk_level TEXT, + embedding VECTOR, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.4 topics + +```sql +CREATE TABLE topics ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + name TEXT NOT NULL, + source TEXT, + category TEXT, + heat_score NUMERIC, + fit_score NUMERIC, + interaction_score NUMERIC, + extend_score NUMERIC, + risk_score NUMERIC, + final_score NUMERIC, + status TEXT DEFAULT 'candidate', + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.5 formula_pools + +```sql +CREATE TABLE formula_pools ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + type TEXT NOT NULL, + name TEXT NOT NULL, + pattern TEXT, + use_case JSONB, + avoid JSONB, + weight NUMERIC DEFAULT 1, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.6 content_plans + +```sql +CREATE TABLE content_plans ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + topic_id UUID REFERENCES topics(id), + mission TEXT NOT NULL, + target_audience TEXT, + angle TEXT, + opening_type TEXT, + body_type TEXT, + emotion TEXT, + ending_type TEXT, + cta_type TEXT, + risk_level TEXT, + requires_human_review BOOLEAN DEFAULT FALSE, + avoid JSONB, + status TEXT DEFAULT 'planned', + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.7 drafts + +```sql +CREATE TABLE drafts ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + content_plan_id UUID REFERENCES content_plans(id), + content TEXT NOT NULL, + version_name TEXT, + ai_score NUMERIC, + formula_score NUMERIC, + brand_fit_score NUMERIC, + risk_score NUMERIC, + similarity_score NUMERIC, + freshness_score NUMERIC, + status TEXT DEFAULT 'draft', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.8 published_posts + +```sql +CREATE TABLE published_posts ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + draft_id UUID REFERENCES drafts(id), + platform_post_id TEXT, + published_at TIMESTAMP, + publish_mode TEXT, + status TEXT DEFAULT 'published', + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.9 post_metrics + +```sql +CREATE TABLE post_metrics ( + id UUID PRIMARY KEY, + post_id UUID REFERENCES published_posts(id), + collected_at TIMESTAMP NOT NULL, + impressions INT, + likes INT, + replies INT, + reposts INT, + quotes INT, + follows_gained INT, + profile_visits INT, + engagement_rate NUMERIC, + metadata JSONB, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.10 strategy_versions + +```sql +CREATE TABLE strategy_versions ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + version TEXT NOT NULL, + content_mix JSONB, + opening_weights JSONB, + cta_weights JSONB, + formula_weights JSONB, + posting_windows JSONB, + change_reason JSONB, + is_active BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 16.11 learning_events + +```sql +CREATE TABLE learning_events ( + id UUID PRIMARY KEY, + account_id UUID REFERENCES accounts(id), + post_id UUID REFERENCES published_posts(id), + insight_type TEXT, + finding TEXT, + recommendation TEXT, + confidence NUMERIC, + applied_status TEXT DEFAULT 'pending', + created_at TIMESTAMP DEFAULT NOW() +); +``` + +--- + +## 17. API Endpoint 草案 + +### 17.1 Account + +```text +POST /api/accounts +GET /api/accounts/:id +PATCH /api/accounts/:id +GET /api/accounts/:id/brain +POST /api/accounts/:id/rebuild-brain +``` + +### 17.2 Source / Ingestion + +```text +POST /api/accounts/:id/sources/upload +POST /api/accounts/:id/sources/url +POST /api/accounts/:id/sources/obsidian-import +GET /api/accounts/:id/sources +POST /api/sources/:source_id/parse +``` + +### 17.3 Topic Radar + +```text +POST /api/accounts/:id/topic-radar/scan +GET /api/accounts/:id/topics +PATCH /api/topics/:topic_id +POST /api/topics/:topic_id/create-plan +``` + +### 17.4 Content Plan / Draft + +```text +POST /api/accounts/:id/content-plans +GET /api/accounts/:id/content-plans +POST /api/content-plans/:id/generate-drafts +GET /api/content-plans/:id/drafts +PATCH /api/drafts/:id +POST /api/drafts/:id/quality-check +POST /api/drafts/:id/approve +POST /api/drafts/:id/reject +``` + +### 17.5 Publishing + +```text +POST /api/drafts/:id/schedule +POST /api/drafts/:id/publish +GET /api/accounts/:id/calendar +``` + +### 17.6 Insights / Learning + +```text +POST /api/published-posts/:id/collect-metrics +GET /api/accounts/:id/performance +POST /api/accounts/:id/analyze-performance +POST /api/accounts/:id/update-strategy +GET /api/accounts/:id/strategy-versions +``` + +--- + +## 18. Worker / Cron Jobs + +### 18.1 每日 Job + +```text +每日 08:00 +- 掃描話題 +- 更新候選題庫 +- 產生今日內容建議 +``` + +```text +每日 10:00 +- 根據今日內容建議產生草稿 +- 執行品質檢查 +- 高信心進排程,中信心進審核 +``` + +```text +每日 12:00 / 21:00 +- 執行排程發文 +``` + +### 18.2 成效回收 Job + +```text +每小時 +- 找出已發布但未收 1h / 6h / 24h / 72h 成效的貼文 +- 呼叫平台 API 收集 insights +- 寫入 post_metrics +``` + +### 18.3 每週學習 Job + +```text +每週一 09:00 +- 分析過去 7 / 14 天成效 +- 產生 learning_events +- 產生 strategy_version draft +- 若 confidence 高,自動套用低風險策略更新 +- 若 confidence 中,進人工確認 +``` + +--- + +## 19. MVP 開發階段 + +### MVP 1:AI 社群助理 + +目標:先證明「找題 + 產文 + 檢查」有價值。 + +功能: + +1. 建立帳號。 +2. 上傳 Excel / Markdown。 +3. 建立 Brand Brain。 +4. 手動輸入關鍵字。 +5. AI 產生候選話題。 +6. AI 產生 Content Plan。 +7. AI 產 3 版草稿。 +8. AI 感與公式感檢查。 +9. 人工核准。 +10. 一鍵複製發文。 + +驗收: + +```text +使用者可以在 10 分鐘內: +建立帳號 → 匯入資料 → 產出 5 篇可用 Threads 草稿。 +``` + +### MVP 2:半自動營運 + +目標:開始排程與成效回收。 + +功能: + +1. Topic Radar 每日自動掃描。 +2. 自動產生每日 3-5 篇候選草稿。 +3. 人工審核。 +4. 排程發文。 +5. 手動或 API 回收成效。 +6. 產生週報。 + +驗收: + +```text +使用者每週只要審核草稿,系統能自動維持一週發文節奏。 +``` + +### MVP 3:低風險自動發布 + +目標:低風險內容自動發,高風險內容人工審。 + +功能: + +1. 自動發布低風險草稿。 +2. 高風險草稿強制人工審核。 +3. 發文後自動回收成效。 +4. 自動更新部分策略權重。 + +驗收: + +```text +系統能連續 14 天自動經營帳號,且不出現高風險錯誤內容。 +``` + +### MVP 4:自我改善閉環 + +目標:讓系統真的越跑越準。 + +功能: + +1. 每週自動分析成效。 +2. 自動調整內容比例。 +3. 自動調整公式權重。 +4. 自動產生內容節奏表。 +5. 生成策略版本與變更原因。 + +驗收: + +```text +系統能說明: +這週為什麼提高互動文比例, +為什麼降低某種開頭, +下週策略和上週有何不同。 +``` + +--- + +## 20. Coding Agent 開發任務建議 + +### Phase 1:資料模型與帳號大腦 + +```text +1. 建立 DB schema。 +2. 建立 Account CRUD。 +3. 建立 Source upload API。 +4. 實作 Excel parser。 +5. 實作 Markdown parser。 +6. 建立 Brand Brain builder。 +7. 將 parsed content 寫入 knowledge_chunks。 +``` + +### Phase 2:話題與策略 + +```text +1. 建立 Topic model。 +2. 實作手動 keyword topic import。 +3. 實作 Topic Score。 +4. 建立 Content Plan generator。 +5. 建立 Formula Pool seed data。 +``` + +### Phase 3:產文與品質檢查 + +```text +1. 建立 Draft generator。 +2. 每個 Content Plan 產出 3 版草稿。 +3. 實作 AI 感檢查。 +4. 實作公式感檢查。 +5. 實作相似度檢查。 +6. 實作人工 approve / reject。 +``` + +### Phase 4:排程與發布 + +```text +1. 建立 Schedule table。 +2. 實作草稿排程。 +3. 實作一鍵複製模式。 +4. 實作 Web Intent fallback。 +5. 預留 Threads API publisher adapter。 +``` + +### Phase 5:成效與學習 + +```text +1. 建立 Published Posts。 +2. 建立 Post Metrics。 +3. 實作手動成效輸入。 +4. 實作 performance analyzer。 +5. 產生 learning_events。 +6. 建立 strategy_versions。 +``` + +--- + +## 21. 產品原則 + +### 21.1 不要做洗稿工具 + +系統可以分析競品,但不能直接複製競品內容。 + +允許: + +```text +分析主題 +分析切角 +分析公式 +分析情緒槓桿 +分析 CTA +分析互動方式 +``` + +禁止: + +```text +直接複製原文 +高度相似改寫 +批量搬運他人貼文 +規避平台規範的自動化抓取 +``` + +### 21.2 人設一致,但形式要變 + +```text +語氣一致 +主題一致 +價值觀一致 + +但: +開頭要變 +結尾要變 +CTA 要變 +長短要變 +內容任務要變 +``` + +### 21.3 高風險內容要保守 + +醫療、金融、法律類內容,預設要查證與人工審核。 + +### 21.4 自我改善要可解釋 + +AI 每次調整策略,都要說明: + +```text +調整了什麼 +為什麼調整 +依據哪段成效 +信心分數多少 +是否需要人工確認 +``` + +--- + +## 22. 最終產品定位 + +這不是一般 AI 發文工具。 + +更精準的定位是: + +```text +AI Threads Account Operator +``` + +或: + +```text +AI Content Brain for Threads +``` + +一句話描述: + +```text +讓 AI 像內容編輯一樣,替每個 Threads 帳號找題、選切角、產文、排程、分析成效,並根據結果持續調整經營策略。 +``` + +產品真正的價值不是「會寫文」,而是: + +```text +知道為什麼要發這篇, +知道這篇打中誰, +知道發完好不好, +知道下一篇要怎麼改。 +``` + +--- + +## 23. 最終閉環摘要 + +```mermaid +flowchart TD + A[輸入帳號資料與知識] --> B[建立帳號大腦] + B --> C[每日找話題] + C --> D[挑選適合帳號的主題] + D --> E[決定內容任務] + E --> F[選公式與切角] + F --> G[產生多版本草稿] + G --> H[品質檢查] + H --> I[排程或人工審核] + I --> J[發布] + J --> K[回收成效] + K --> L[分析有效原因] + L --> M[更新策略權重] + M --> C +``` + +--- + +## 24. 建議第一版畫面 + +### 24.1 帳號大腦頁 + +```text +帳號名稱 +人設 +定位 +語氣 +禁用語 +主題池 +公式池 +知識來源 +歷史貼文 +成效記憶 +``` + +### 24.2 今日話題雷達 + +```text +今日推薦主題 +- 熱度 +- 帳號適配度 +- 互動潛力 +- 風險分數 +- 推薦內容任務 +``` + +### 24.3 草稿工廠 + +```text +草稿列表 +- 內容任務 +- 主題 +- 公式組合 +- AI 感分數 +- 公式感分數 +- 風險分數 +- 狀態 +``` + +### 24.4 成效分析頁 + +```text +已發布貼文 +成效數據 +AI 分析 +下次建議 +是否延伸系列 +``` + +### 24.5 策略版本頁 + +```text +目前策略版本 +內容比例 +公式權重 +CTA 權重 +發布時間 +本次調整原因 +``` + +--- + +## 25. 開發時的最小可交付結果 + +第一個可交付版本只需要做到: + +```text +1. 建立一個帳號。 +2. 上傳一份 Excel。 +3. 系統解析出帳號大腦。 +4. 手動輸入一個主題。 +5. 系統產出 Content Plan。 +6. 系統產出 3 篇 Threads 草稿。 +7. 系統檢查 AI 感與公式感。 +8. 使用者可以 approve / reject。 +9. 系統記住這次人工回饋。 +``` + +這樣就可以開始驗證核心價值。 + +不要一開始就把所有 API、圖譜、全自動發布都做完。 +先讓 AI 真的能寫出「像這個帳號」的內容,再讓它慢慢接管營運。 diff --git a/backend/generate/api/copy_mission.api b/backend/generate/api/copy_mission.api index b95fdb4..cb60235 100644 --- a/backend/generate/api/copy_mission.api +++ b/backend/generate/api/copy_mission.api @@ -165,7 +165,10 @@ type ( } CopyMissionInspirationReq { - Keyword string `json:"keyword,optional"` + Keyword string `json:"keyword,optional"` + ContentDirection string `json:"content_direction,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + AvoidTopics []string `json:"avoid_topics,optional"` } CopyMissionInspirationHandlerReq { @@ -267,7 +270,7 @@ type ( Message string `json:"message"` } - ScheduleCopyDraftsReq { + CopyMissionScheduleCopyDraftsReq { AccountID string `json:"account_id" validate:"required"` DraftIDs []string `json:"draft_ids" validate:"required,min=1"` StartAt int64 `json:"start_at,optional"` @@ -278,10 +281,10 @@ type ( ScheduleCopyMissionDraftsHandlerReq { CopyMissionPath - ScheduleCopyDraftsReq + CopyMissionScheduleCopyDraftsReq } - ScheduleCopyDraftsData { + CopyMissionScheduleCopyDraftsData { Scheduled int `json:"scheduled"` List []PublishQueueItemData `json:"list"` Message string `json:"message"` @@ -295,11 +298,22 @@ type ( } CopyMissionInspirationData { + TopicCandidateID string `json:"topic_candidate_id,omitempty"` + ContentPlanID string `json:"content_plan_id,omitempty"` Label string `json:"label"` SeedQuery string `json:"seed_query"` Brief string `json:"brief"` TrendReason string `json:"trend_reason,omitempty"` TrendKeywords []string `json:"trend_keywords,omitempty"` + Angles []string `json:"angles,omitempty"` + Mission string `json:"mission,omitempty"` + TargetAudience string `json:"target_audience,omitempty"` + OpeningType string `json:"opening_type,omitempty"` + BodyType string `json:"body_type,omitempty"` + Emotion string `json:"emotion,omitempty"` + CtaType string `json:"cta_type,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + Avoid []string `json:"avoid,omitempty"` Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"` WebSearchUsed bool `json:"web_search_used"` Message string `json:"message"` @@ -357,7 +371,7 @@ service gateway { post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData) @handler scheduleCopyMissionDrafts - post /:personaId/copy-missions/:id/copy-drafts/schedule (ScheduleCopyMissionDraftsHandlerReq) returns (ScheduleCopyDraftsData) + post /:personaId/copy-missions/:id/copy-drafts/schedule (ScheduleCopyMissionDraftsHandlerReq) returns (CopyMissionScheduleCopyDraftsData) @handler getCopyMissionScanSchedule get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData) diff --git a/backend/generate/api/persona.api b/backend/generate/api/persona.api index b268ca4..e363ff0 100644 --- a/backend/generate/api/persona.api +++ b/backend/generate/api/persona.api @@ -64,6 +64,23 @@ type ( StartPersonaStyleAnalysisReq } + StartPersonaStyleAnalysisFromTextReq { + ReferenceTexts []string `json:"reference_texts,optional"` + RawText string `json:"raw_text,optional"` + SourceLabel string `json:"source_label,optional"` + } + + StartPersonaStyleAnalysisFromTextData { + Persona PersonaData `json:"persona"` + PostCount int `json:"post_count"` + Message string `json:"message"` + } + + StartPersonaStyleAnalysisFromTextHandlerReq { + PersonaPath + StartPersonaStyleAnalysisFromTextReq + } + StartPersonaViralScanJobReq { Keywords []string `json:"keywords,optional"` } @@ -113,17 +130,27 @@ type ( CopyDraftData { ID string `json:"id"` PersonaID string `json:"persona_id"` + ContentPlanID string `json:"content_plan_id,omitempty"` 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"` + TopicTag string `json:"topic_tag,omitempty"` Angle string `json:"angle,omitempty"` Hook string `json:"hook,omitempty"` Rationale string `json:"rationale,omitempty"` ReferenceNotes string `json:"reference_notes,omitempty"` Sources []string `json:"sources,omitempty"` + AiScore int `json:"ai_score,omitempty"` + FormulaScore int `json:"formula_score,omitempty"` + BrandFitScore int `json:"brand_fit_score,omitempty"` + RiskScore int `json:"risk_score,omitempty"` + SimilarityScore int `json:"similarity_score,omitempty"` + EngagementPotential int `json:"engagement_potential,omitempty"` + FreshnessScore int `json:"freshness_score,omitempty"` + ReviewSuggestion string `json:"review_suggestion,omitempty"` Status string `json:"status,omitempty"` PublishQueueID string `json:"publish_queue_id,omitempty"` PublishedMediaID string `json:"published_media_id,omitempty"` @@ -157,10 +184,202 @@ type ( } UpdateCopyDraftReq { - Text *string `json:"text,optional"` - Hook *string `json:"hook,optional"` - Angle *string `json:"angle,optional"` - Status *string `json:"status,optional"` + Text *string `json:"text,optional"` + TopicTag *string `json:"topic_tag,optional"` + Hook *string `json:"hook,optional"` + Angle *string `json:"angle,optional"` + Rationale *string `json:"rationale,optional"` + Status *string `json:"status,optional"` + } + + TopicCandidateData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Name string `json:"name"` + Source string `json:"source,omitempty"` + Category string `json:"category,omitempty"` + SeedQuery string `json:"seed_query,omitempty"` + TrendReason string `json:"trend_reason,omitempty"` + TrendKeywords []string `json:"trend_keywords,omitempty"` + HeatScore int `json:"heat_score,omitempty"` + FitScore int `json:"fit_score,omitempty"` + InteractionScore int `json:"interaction_score,omitempty"` + ExtendScore int `json:"extend_score,omitempty"` + RiskScore int `json:"risk_score,omitempty"` + FinalScore float64 `json:"final_score,omitempty"` + RecommendedMissions []string `json:"recommended_missions,omitempty"` + Status string `json:"status,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + ContentPlanData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + TopicCandidateID string `json:"topic_candidate_id,omitempty"` + Topic string `json:"topic"` + Mission string `json:"mission"` + TargetAudience string `json:"target_audience,omitempty"` + Angle string `json:"angle,omitempty"` + OpeningType string `json:"opening_type,omitempty"` + BodyType string `json:"body_type,omitempty"` + Emotion string `json:"emotion,omitempty"` + EndingType string `json:"ending_type,omitempty"` + CtaType string `json:"cta_type,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + RequiresHumanReview bool `json:"requires_human_review,omitempty"` + Avoid []string `json:"avoid,omitempty"` + SelectedKnowledge []string `json:"selected_knowledge,omitempty"` + Status string `json:"status,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + FeedbackEventData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + ContentPlanID string `json:"content_plan_id,omitempty"` + DraftID string `json:"draft_id,omitempty"` + Decision string `json:"decision"` + Note string `json:"note,omitempty"` + Snapshot string `json:"snapshot,omitempty"` + CreateAt int64 `json:"create_at"` + } + + KnowledgeSourceData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + SourceType string `json:"source_type"` + Title string `json:"title,omitempty"` + Filename string `json:"filename,omitempty"` + ParsedStatus string `json:"parsed_status,omitempty"` + ChunkCount int `json:"chunk_count,omitempty"` + CreateAt int64 `json:"create_at"` + } + + KnowledgeChunkData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + SourceID string `json:"source_id"` + Content string `json:"content"` + Topics []string `json:"topics,omitempty"` + StyleTags []string `json:"style_tags,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + CreateAt int64 `json:"create_at"` + } + + FormulaPoolData { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Type string `json:"type"` + Name string `json:"name"` + Pattern string `json:"pattern,omitempty"` + UseCases []string `json:"use_cases,omitempty"` + Avoid []string `json:"avoid,omitempty"` + Weight int `json:"weight,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + CreateKnowledgeSourceReq { + SourceType string `json:"source_type" validate:"required"` + Title string `json:"title,optional"` + Filename string `json:"filename,optional"` + Content string `json:"content,optional"` + ContentBase64 string `json:"content_base64,optional"` + } + + CreateKnowledgeSourceHandlerReq { + PersonaPath + CreateKnowledgeSourceReq + } + + ListKnowledgeSourcesData { List []KnowledgeSourceData `json:"list"` } + ListKnowledgeChunksData { List []KnowledgeChunkData `json:"list"` } + ListFormulaPoolsData { List []FormulaPoolData `json:"list"` } + + CreateFormulaPoolReq { + Type string `json:"type" validate:"required"` + Name string `json:"name" validate:"required"` + Pattern string `json:"pattern,optional"` + UseCases []string `json:"use_cases,optional"` + Avoid []string `json:"avoid,optional"` + Weight int `json:"weight,optional"` + } + + CreateFormulaPoolHandlerReq { + PersonaPath + CreateFormulaPoolReq + } + + ListTopicCandidatesData { + List []TopicCandidateData `json:"list"` + } + + ListContentPlansData { + List []ContentPlanData `json:"list"` + } + + CreateContentPlanReq { + TopicCandidateID string `json:"topic_candidate_id,optional"` + Topic string `json:"topic" validate:"required"` + Mission string `json:"mission" validate:"required"` + TargetAudience string `json:"target_audience,optional"` + Angle string `json:"angle,optional"` + OpeningType string `json:"opening_type,optional"` + BodyType string `json:"body_type,optional"` + Emotion string `json:"emotion,optional"` + EndingType string `json:"ending_type,optional"` + CtaType string `json:"cta_type,optional"` + RiskLevel string `json:"risk_level,optional"` + RequiresHumanReview bool `json:"requires_human_review,optional"` + Avoid []string `json:"avoid,optional"` + SelectedKnowledge []string `json:"selected_knowledge,optional"` + } + + CreateContentPlanHandlerReq { + PersonaPath + CreateContentPlanReq + } + + ContentPlanPath { + ID string `path:"id" validate:"required"` + ContentPlanID string `path:"contentPlanId" validate:"required"` + } + + UpdateContentPlanReq { + Topic *string `json:"topic,optional"` + Mission *string `json:"mission,optional"` + TargetAudience *string `json:"target_audience,optional"` + Angle *string `json:"angle,optional"` + OpeningType *string `json:"opening_type,optional"` + BodyType *string `json:"body_type,optional"` + Emotion *string `json:"emotion,optional"` + EndingType *string `json:"ending_type,optional"` + CtaType *string `json:"cta_type,optional"` + RiskLevel *string `json:"risk_level,optional"` + RequiresHumanReview *bool `json:"requires_human_review,optional"` + Avoid []string `json:"avoid,optional"` + SelectedKnowledge []string `json:"selected_knowledge,optional"` + Status *string `json:"status,optional"` + } + + UpdateContentPlanHandlerReq { + ContentPlanPath + UpdateContentPlanReq + } + + CreateFeedbackEventReq { + ContentPlanID string `json:"content_plan_id,optional"` + DraftID string `json:"draft_id,optional"` + Decision string `json:"decision" validate:"required"` + Note string `json:"note,optional"` + Snapshot string `json:"snapshot,optional"` + } + + CreateFeedbackEventHandlerReq { + PersonaPath + CreateFeedbackEventReq } UpdateCopyDraftHandlerReq { @@ -169,8 +388,9 @@ type ( } PublishCopyDraftReq { - Text string `json:"text,optional"` - Confirm bool `json:"confirm"` + Text string `json:"text,optional"` + TopicTag string `json:"topic_tag,optional"` + Confirm bool `json:"confirm"` } PublishCopyDraftHandlerReq { @@ -186,6 +406,21 @@ type ( Message string `json:"message"` } + ScheduleCopyDraftsReq { + AccountID string `json:"account_id" validate:"required"` + DraftIDs []string `json:"draft_ids" validate:"required,min=1"` + StartAt int64 `json:"start_at,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + Mode string `json:"mode,optional"` + } + + ScheduleCopyDraftsData { + Scheduled int `json:"scheduled"` + List []PublishQueueItemData `json:"list"` + Message string `json:"message"` + } + SchedulePersonaDraftsHandlerReq { PersonaPath ScheduleCopyDraftsReq @@ -231,6 +466,21 @@ type ( Message string `json:"message"` } + PrunePersonaCopyDraftsReq { + Keep int `json:"keep,optional"` + } + + PrunePersonaCopyDraftsHandlerReq { + PersonaPath + PrunePersonaCopyDraftsReq + } + + PrunePersonaCopyDraftsData { + Deleted int64 `json:"deleted"` + Kept int `json:"kept"` + Message string `json:"message"` + } + ListPersonaContentInboxReq { Page int64 `form:"page,optional"` PageSize int64 `form:"pageSize,optional"` @@ -259,6 +509,7 @@ type ( SchedulePersonaCopyDraftReq { AccountID string `json:"account_id" validate:"required"` + TopicTag string `json:"topic_tag,optional"` ScheduledAt int64 `json:"scheduled_at,optional"` } @@ -289,6 +540,82 @@ type ( List []CopyDraftData `json:"list"` Message string `json:"message"` } + + GeneratePersonaTopicMatrixReq { + Topic string `json:"topic" validate:"required"` + ContentPlanID string `json:"content_plan_id,optional"` + Brief string `json:"brief,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` + } + + GeneratePersonaTopicMatrixHandlerReq { + PersonaPath + GeneratePersonaTopicMatrixReq + } + + GeneratePersonaTopicMatrixData { + List []CopyDraftData `json:"list"` + Message string `json:"message"` + } + + StartPersonaTopicMatrixJobHandlerReq { + PersonaPath + GeneratePersonaTopicMatrixReq + } + + StartPersonaTopicMatrixJobData { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` + } + + StartPersonaFormulaDraftJobReq { + AccountID string `json:"account_id" validate:"required"` + FormulaID string `json:"formula_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"` + } + + StartPersonaFormulaDraftJobHandlerReq { + PersonaPath + StartPersonaFormulaDraftJobReq + } + + StartPersonaFormulaDraftJobData { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` + } + + StartPersonaRewriteDraftJobReq { + AccountID string `json:"account_id" validate:"required"` + ReferenceText string `json:"reference_text" validate:"required"` + Topic string `json:"topic" validate:"required"` + Brief string `json:"brief,optional"` + SaveLabel string `json:"save_label,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` + } + + StartPersonaRewriteDraftJobHandlerReq { + PersonaPath + StartPersonaRewriteDraftJobReq + } + + StartPersonaRewriteDraftJobData { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` + } ) @server( @@ -317,6 +644,9 @@ service gateway { @handler startPersonaStyleAnalysis post /:id/style-analysis (StartPersonaStyleAnalysisHandlerReq) returns (StartPersonaStyleAnalysisData) + @handler startPersonaStyleAnalysisFromText + post /:id/style-analysis-from-text (StartPersonaStyleAnalysisFromTextHandlerReq) returns (StartPersonaStyleAnalysisFromTextData) + @handler startPersonaViralScanJob post /:id/viral-scan-jobs (StartPersonaViralScanJobHandlerReq) returns (StartPersonaViralScanJobData) @@ -329,6 +659,36 @@ service gateway { @handler listPersonaContentInbox get /:id/content-inbox (ListPersonaContentInboxHandlerReq) returns (ListPersonaContentInboxData) + @handler listTopicCandidates + get /:id/topic-candidates (PersonaPath) returns (ListTopicCandidatesData) + + @handler listContentPlans + get /:id/content-plans (PersonaPath) returns (ListContentPlansData) + + @handler createContentPlan + post /:id/content-plans (CreateContentPlanHandlerReq) returns (ContentPlanData) + + @handler updateContentPlan + patch /:id/content-plans/:contentPlanId (UpdateContentPlanHandlerReq) returns (ContentPlanData) + + @handler createFeedbackEvent + post /:id/feedback-events (CreateFeedbackEventHandlerReq) returns (FeedbackEventData) + + @handler createKnowledgeSource + post /:id/knowledge-sources (CreateKnowledgeSourceHandlerReq) returns (KnowledgeSourceData) + + @handler listKnowledgeSources + get /:id/knowledge-sources (PersonaPath) returns (ListKnowledgeSourcesData) + + @handler listKnowledgeChunks + get /:id/knowledge-chunks (PersonaPath) returns (ListKnowledgeChunksData) + + @handler listFormulaPools + get /:id/formula-pools (PersonaPath) returns (ListFormulaPoolsData) + + @handler createFormulaPool + post /:id/formula-pools (CreateFormulaPoolHandlerReq) returns (FormulaPoolData) + @handler generatePersonaCopyDraft post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData) @@ -347,9 +707,24 @@ service gateway { @handler generateFromContentFormula post /:id/content-formulas/:formulaId/generate (GenerateFromContentFormulaHandlerReq) returns (GenerateFromContentFormulaData) + @handler generatePersonaTopicMatrix + post /:id/topic-matrix/generate (GeneratePersonaTopicMatrixHandlerReq) returns (GeneratePersonaTopicMatrixData) + + @handler startPersonaTopicMatrixJob + post /:id/topic-matrix/jobs (StartPersonaTopicMatrixJobHandlerReq) returns (StartPersonaTopicMatrixJobData) + + @handler startPersonaFormulaDraftJob + post /:id/formula-draft/jobs (StartPersonaFormulaDraftJobHandlerReq) returns (StartPersonaFormulaDraftJobData) + + @handler startPersonaRewriteDraftJob + post /:id/rewrite-draft/jobs (StartPersonaRewriteDraftJobHandlerReq) returns (StartPersonaRewriteDraftJobData) + @handler deletePersonaCopyDraft delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData) + @handler prunePersonaCopyDrafts + post /:id/copy-drafts/prune (PrunePersonaCopyDraftsHandlerReq) returns (PrunePersonaCopyDraftsData) + @handler listStylePresets get /:id/style-presets (PersonaPath) returns (ListStylePresetsData) @@ -358,4 +733,4 @@ service gateway { @handler deleteStylePreset delete /:id/style-presets/:presetId (StylePresetPath) -} \ No newline at end of file +} diff --git a/backend/internal/bootstrap/init.go b/backend/internal/bootstrap/init.go index 5b00b39..baf77c2 100644 --- a/backend/internal/bootstrap/init.go +++ b/backend/internal/bootstrap/init.go @@ -8,19 +8,31 @@ import ( libcrypto "haixun-backend/internal/library/crypto" libmongo "haixun-backend/internal/library/mongo" brandrepo "haixun-backend/internal/model/brand/repository" + contentformularepo "haixun-backend/internal/model/content_formula/repository" cmatrixrepo "haixun-backend/internal/model/content_matrix/repository" + contentopsrepo "haixun-backend/internal/model/content_ops/repository" copydraftrepo "haixun-backend/internal/model/copy_draft/repository" copymissionrepo "haixun-backend/internal/model/copy_mission/repository" + crmcontactrepo "haixun-backend/internal/model/crm_contact/repository" jobrepo "haixun-backend/internal/model/job/repository" + jobusecase "haixun-backend/internal/model/job/usecase" kgrepo "haixun-backend/internal/model/knowledge_graph/repository" memberrepo "haixun-backend/internal/model/member/repository" + mentioninboxrepo "haixun-backend/internal/model/mention_inbox/repository" outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository" + ownpostformularepo "haixun-backend/internal/model/own_post_formula/repository" permissionrepo "haixun-backend/internal/model/permission/repository" permissionuc "haixun-backend/internal/model/permission/usecase" personarepo "haixun-backend/internal/model/persona/repository" placementtopicrepo "haixun-backend/internal/model/placement_topic/repository" + publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository" + publishguardrepo "haixun-backend/internal/model/publish_guard/repository" + publishinventoryrepo "haixun-backend/internal/model/publish_inventory/repository" + publishqueuerepo "haixun-backend/internal/model/publish_queue/repository" + publishqueueeventrepo "haixun-backend/internal/model/publish_queue_event/repository" scanpostrepo "haixun-backend/internal/model/scan_post/repository" settingrepo "haixun-backend/internal/model/setting/repository" + stylepresetrepo "haixun-backend/internal/model/style_preset/repository" threadsaccountrepo "haixun-backend/internal/model/threads_account/repository" ) @@ -97,6 +109,17 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport {"placement_topics", placementtopicrepo.NewMongoRepository(db).EnsureIndexes}, {"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes}, {"threads_account_secrets", threadsaccountrepo.NewSecretsMongoRepository(db, secretsCipher).EnsureIndexes}, + {"publish_analytics", publishanalyticsrepo.NewMongoRepository(db).EnsureIndexes}, + {"publish_queue", publishqueuerepo.NewMongoRepository(db).EnsureIndexes}, + {"publish_inventory", publishinventoryrepo.NewMongoRepository(db).EnsureIndexes}, + {"publish_guard", publishguardrepo.NewMongoRepository(db).EnsureIndexes}, + {"publish_queue_events", publishqueueeventrepo.NewMongoRepository(db).EnsureIndexes}, + {"style_presets", stylepresetrepo.NewMongoRepository(db).EnsureIndexes}, + {"own_post_formulas", ownpostformularepo.NewMongoRepository(db).EnsureIndexes}, + {"content_formulas", contentformularepo.NewMongoRepository(db).EnsureIndexes}, + {"content_ops", contentopsrepo.NewMongoRepository(db).EnsureIndexes}, + {"mention_inbox", mentioninboxrepo.NewMongoRepository(db).EnsureIndexes}, + {"crm_contacts", crmcontactrepo.NewMongoRepository(db).EnsureIndexes}, } for _, repo := range repos { if err := repo.fn(ctx); err != nil { @@ -105,6 +128,26 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport } report.IndexesEnsured = true + jobUseCase := jobusecase.NewUseCase(jobTemplateRepository, jobRunRepository, jobScheduleRepository, jobEventRepository, nil) + jobTemplates := []struct { + name string + fn func(context.Context) error + }{ + {"demo", jobUseCase.EnsureDemoTemplate}, + {"style_8d", jobUseCase.EnsureStyle8DTemplate}, + {"expand_graph", jobUseCase.EnsureExpandGraphTemplate}, + {"placement_scan", jobUseCase.EnsurePlacementScanTemplate}, + {"scan_viral", jobUseCase.EnsureScanViralTemplate}, + {"generate_outreach_draft", jobUseCase.EnsureGenerateOutreachDraftTemplate}, + {"refresh_threads_token", jobUseCase.EnsureRefreshThreadsTokenTemplate}, + {"publish_analytics", jobUseCase.EnsurePublishAnalyticsTemplate}, + } + for _, template := range jobTemplates { + if err := template.fn(ctx); err != nil { + return nil, fmt.Errorf("seed %s job template: %w", template.name, err) + } + } + permissionUseCase := permissionuc.NewUseCase(permissionRepository, rolePermissionRepository) if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil { return nil, fmt.Errorf("seed permissions catalog: %w", err) diff --git a/backend/internal/handler/copy_mission/create_copy_mission_handler.go b/backend/internal/handler/copy_mission/create_copy_mission_handler.go index b97e098..870a129 100644 --- a/backend/internal/handler/copy_mission/create_copy_mission_handler.go +++ b/backend/internal/handler/copy_mission/create_copy_mission_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func CreateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/delete_copy_mission_handler.go b/backend/internal/handler/copy_mission/delete_copy_mission_handler.go index cedeb8d..2340be4 100644 --- a/backend/internal/handler/copy_mission/delete_copy_mission_handler.go +++ b/backend/internal/handler/copy_mission/delete_copy_mission_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func DeleteCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go b/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go index db5e4bb..f114ea8 100644 --- a/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go +++ b/backend/internal/handler/copy_mission/generate_copy_mission_matrix_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func GenerateCopyMissionMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/get_copy_mission_handler.go b/backend/internal/handler/copy_mission/get_copy_mission_handler.go index 6034abd..7cbb8d2 100644 --- a/backend/internal/handler/copy_mission/get_copy_mission_handler.go +++ b/backend/internal/handler/copy_mission/get_copy_mission_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func GetCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go b/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go index 9737c28..493d849 100644 --- a/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go +++ b/backend/internal/handler/copy_mission/get_copy_mission_scan_schedule_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func GetCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go b/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go index 5d0f751..74849bb 100644 --- a/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go +++ b/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go @@ -1,14 +1,16 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go b/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go index ff4fe54..00db1ea 100644 --- a/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go +++ b/backend/internal/handler/copy_mission/list_copy_mission_copy_drafts_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func ListCopyMissionCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go b/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go index 1f8705b..460684b 100644 --- a/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go +++ b/backend/internal/handler/copy_mission/list_copy_mission_scan_posts_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func ListCopyMissionScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/list_copy_missions_handler.go b/backend/internal/handler/copy_mission/list_copy_missions_handler.go index 3d82016..0ee198d 100644 --- a/backend/internal/handler/copy_mission/list_copy_missions_handler.go +++ b/backend/internal/handler/copy_mission/list_copy_missions_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func ListCopyMissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go index 8b550a5..1348728 100644 --- a/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go +++ b/backend/internal/handler/copy_mission/start_copy_mission_analyze_job_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func StartCopyMissionAnalyzeJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go index e76ce7f..fe5dda0 100644 --- a/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go +++ b/backend/internal/handler/copy_mission/start_copy_mission_copy_draft_job_handler.go @@ -1,14 +1,16 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func StartCopyMissionCopyDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go index b626e5c..2cbcb84 100644 --- a/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go +++ b/backend/internal/handler/copy_mission/start_copy_mission_matrix_job_handler.go @@ -1,14 +1,16 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func StartCopyMissionMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go b/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go index 4309303..d9bbed4 100644 --- a/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go +++ b/backend/internal/handler/copy_mission/start_copy_mission_scan_job_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func StartCopyMissionScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/update_copy_mission_handler.go b/backend/internal/handler/copy_mission/update_copy_mission_handler.go index 580a256..b766055 100644 --- a/backend/internal/handler/copy_mission/update_copy_mission_handler.go +++ b/backend/internal/handler/copy_mission/update_copy_mission_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func UpdateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go b/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go index 4beed7c..9819673 100644 --- a/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go +++ b/backend/internal/handler/copy_mission/upsert_copy_mission_scan_schedule_handler.go @@ -6,12 +6,11 @@ package copy_mission import ( "net/http" + "github.com/zeromicro/go-zero/rest/httpx" "haixun-backend/internal/logic/copy_mission" "haixun-backend/internal/response" "haixun-backend/internal/svc" "haixun-backend/internal/types" - - "github.com/zeromicro/go-zero/rest/httpx" ) func UpsertCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { diff --git a/backend/internal/handler/persona/create_content_plan_handler.go b/backend/internal/handler/persona/create_content_plan_handler.go new file mode 100644 index 0000000..33264ab --- /dev/null +++ b/backend/internal/handler/persona/create_content_plan_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CreateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateContentPlanHandlerReq + 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.NewCreateContentPlanLogic(r.Context(), svcCtx) + data, err := l.CreateContentPlan(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/create_feedback_event_handler.go b/backend/internal/handler/persona/create_feedback_event_handler.go new file mode 100644 index 0000000..6085fc3 --- /dev/null +++ b/backend/internal/handler/persona/create_feedback_event_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CreateFeedbackEventHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateFeedbackEventHandlerReq + 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.NewCreateFeedbackEventLogic(r.Context(), svcCtx) + data, err := l.CreateFeedbackEvent(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/create_formula_pool_handler.go b/backend/internal/handler/persona/create_formula_pool_handler.go new file mode 100644 index 0000000..209740e --- /dev/null +++ b/backend/internal/handler/persona/create_formula_pool_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CreateFormulaPoolHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateFormulaPoolHandlerReq + 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.NewCreateFormulaPoolLogic(r.Context(), svcCtx) + data, err := l.CreateFormulaPool(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/create_knowledge_source_handler.go b/backend/internal/handler/persona/create_knowledge_source_handler.go new file mode 100644 index 0000000..5dcff2a --- /dev/null +++ b/backend/internal/handler/persona/create_knowledge_source_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CreateKnowledgeSourceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateKnowledgeSourceHandlerReq + 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.NewCreateKnowledgeSourceLogic(r.Context(), svcCtx) + data, err := l.CreateKnowledgeSource(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/generate_from_content_formula_handler.go b/backend/internal/handler/persona/generate_from_content_formula_handler.go index 11e093f..b874afc 100644 --- a/backend/internal/handler/persona/generate_from_content_formula_handler.go +++ b/backend/internal/handler/persona/generate_from_content_formula_handler.go @@ -26,4 +26,4 @@ func GenerateFromContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerF 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/generate_persona_topic_matrix_handler.go b/backend/internal/handler/persona/generate_persona_topic_matrix_handler.go new file mode 100644 index 0000000..3148c38 --- /dev/null +++ b/backend/internal/handler/persona/generate_persona_topic_matrix_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 GeneratePersonaTopicMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GeneratePersonaTopicMatrixHandlerReq + 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.NewGeneratePersonaTopicMatrixLogic(r.Context(), svcCtx) + data, err := l.GeneratePersonaTopicMatrix(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_content_plans_handler.go b/backend/internal/handler/persona/list_content_plans_handler.go new file mode 100644 index 0000000..850b3c7 --- /dev/null +++ b/backend/internal/handler/persona/list_content_plans_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListContentPlansHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaPath + 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.NewListContentPlansLogic(r.Context(), svcCtx) + data, err := l.ListContentPlans(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_formula_pools_handler.go b/backend/internal/handler/persona/list_formula_pools_handler.go new file mode 100644 index 0000000..6759aa6 --- /dev/null +++ b/backend/internal/handler/persona/list_formula_pools_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListFormulaPoolsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaPath + 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.NewListFormulaPoolsLogic(r.Context(), svcCtx) + data, err := l.ListFormulaPools(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_knowledge_chunks_handler.go b/backend/internal/handler/persona/list_knowledge_chunks_handler.go new file mode 100644 index 0000000..f4d6649 --- /dev/null +++ b/backend/internal/handler/persona/list_knowledge_chunks_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListKnowledgeChunksHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaPath + 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.NewListKnowledgeChunksLogic(r.Context(), svcCtx) + data, err := l.ListKnowledgeChunks(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_knowledge_sources_handler.go b/backend/internal/handler/persona/list_knowledge_sources_handler.go new file mode 100644 index 0000000..6dca00d --- /dev/null +++ b/backend/internal/handler/persona/list_knowledge_sources_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListKnowledgeSourcesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaPath + 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.NewListKnowledgeSourcesLogic(r.Context(), svcCtx) + data, err := l.ListKnowledgeSources(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/list_persona_content_inbox_handler.go b/backend/internal/handler/persona/list_persona_content_inbox_handler.go index 42f15c7..efd7e41 100644 --- a/backend/internal/handler/persona/list_persona_content_inbox_handler.go +++ b/backend/internal/handler/persona/list_persona_content_inbox_handler.go @@ -26,4 +26,4 @@ func ListPersonaContentInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc 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/list_topic_candidates_handler.go b/backend/internal/handler/persona/list_topic_candidates_handler.go new file mode 100644 index 0000000..f5c56ea --- /dev/null +++ b/backend/internal/handler/persona/list_topic_candidates_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListTopicCandidatesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PersonaPath + 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.NewListTopicCandidatesLogic(r.Context(), svcCtx) + data, err := l.ListTopicCandidates(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go b/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go new file mode 100644 index 0000000..fd51cde --- /dev/null +++ b/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go @@ -0,0 +1,30 @@ +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 PrunePersonaCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PrunePersonaCopyDraftsHandlerReq + 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.NewPrunePersonaCopyDraftsLogic(r.Context(), svcCtx) + data, err := l.PrunePersonaCopyDrafts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go b/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go index 0812ea0..a799be6 100644 --- a/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go +++ b/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go @@ -26,4 +26,4 @@ func SchedulePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFun data, err := l.SchedulePersonaCopyDraft(&req) response.Write(r.Context(), w, data, err) } -} \ No newline at end of file +} diff --git a/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go b/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go new file mode 100644 index 0000000..2d0db5a --- /dev/null +++ b/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go @@ -0,0 +1,30 @@ +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 StartPersonaFormulaDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPersonaFormulaDraftJobHandlerReq + 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.NewStartPersonaFormulaDraftJobLogic(r.Context(), svcCtx) + data, err := l.StartPersonaFormulaDraftJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go b/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go new file mode 100644 index 0000000..eac2b0d --- /dev/null +++ b/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func StartPersonaRewriteDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPersonaRewriteDraftJobHandlerReq + 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.NewStartPersonaRewriteDraftJobLogic(r.Context(), svcCtx) + data, err := l.StartPersonaRewriteDraftJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go b/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go new file mode 100644 index 0000000..ae9dc93 --- /dev/null +++ b/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go @@ -0,0 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +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 StartPersonaStyleAnalysisFromTextHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPersonaStyleAnalysisFromTextHandlerReq + 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.NewStartPersonaStyleAnalysisFromTextLogic(r.Context(), svcCtx) + data, err := l.StartPersonaStyleAnalysisFromText(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go b/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go new file mode 100644 index 0000000..108a6dc --- /dev/null +++ b/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go @@ -0,0 +1,30 @@ +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 StartPersonaTopicMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPersonaTopicMatrixJobHandlerReq + 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.NewStartPersonaTopicMatrixJobLogic(r.Context(), svcCtx) + data, err := l.StartPersonaTopicMatrixJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/update_content_plan_handler.go b/backend/internal/handler/persona/update_content_plan_handler.go new file mode 100644 index 0000000..ba1bb32 --- /dev/null +++ b/backend/internal/handler/persona/update_content_plan_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/persona" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func UpdateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpdateContentPlanHandlerReq + 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.NewUpdateContentPlanLogic(r.Context(), svcCtx) + data, err := l.UpdateContentPlan(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index 943d0a0..efaebe0 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -592,6 +592,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/content-inbox", Handler: persona.ListPersonaContentInboxHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/:id/content-plans", + Handler: persona.ListContentPlansHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/content-plans", + Handler: persona.CreateContentPlanHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/:id/content-plans/:contentPlanId", + Handler: persona.UpdateContentPlanHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/:id/copy-drafts", @@ -622,16 +637,66 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/copy-drafts/generate", Handler: persona.GeneratePersonaCopyDraftHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:id/copy-drafts/prune", + Handler: persona.PrunePersonaCopyDraftsHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/copy-drafts/schedule", Handler: persona.SchedulePersonaDraftsHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:id/feedback-events", + Handler: persona.CreateFeedbackEventHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/formula-draft/jobs", + Handler: persona.StartPersonaFormulaDraftJobHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/formula-pools", + Handler: persona.ListFormulaPoolsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/formula-pools", + Handler: persona.CreateFormulaPoolHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/knowledge-chunks", + Handler: persona.ListKnowledgeChunksHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/knowledge-sources", + Handler: persona.CreateKnowledgeSourceHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/knowledge-sources", + Handler: persona.ListKnowledgeSourcesHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/rewrite-draft/jobs", + Handler: persona.StartPersonaRewriteDraftJobHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/style-analysis", Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:id/style-analysis-from-text", + Handler: persona.StartPersonaStyleAnalysisFromTextHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/:id/style-presets", @@ -647,6 +712,21 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/style-presets/:presetId", Handler: persona.DeleteStylePresetHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/:id/topic-candidates", + Handler: persona.ListTopicCandidatesHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/topic-matrix/generate", + Handler: persona.GeneratePersonaTopicMatrixHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/topic-matrix/jobs", + Handler: persona.StartPersonaTopicMatrixJobHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/viral-scan-jobs", diff --git a/backend/internal/library/copyvoice/human.go b/backend/internal/library/copyvoice/human.go new file mode 100644 index 0000000..ac3352b --- /dev/null +++ b/backend/internal/library/copyvoice/human.go @@ -0,0 +1,50 @@ +package copyvoice + +import "strings" + +// SystemRules are shared instructions for human-like Threads copy generation. +func SystemRules() string { + return strings.TrimSpace(`寫作目標:像真人在 Threads / Instagram 發文,不是在套模板。若有人設語言指紋,必須優先照它的說話習慣寫。 + +敘事(比結構重要): +- 依主題選內容型態:心情陪伴、知識整理、清單工具、互動提問、輕幽默共鳴等,不要每篇都同一套。 +- 可以寫具體場景,也可以先承接情緒或整理資訊;不要為了故事感硬編不存在的生活細節。 +- 開場、推進方式、收尾都要自然變化;禁止固定三段式、固定問句收尾、固定金句收束。 + +仿寫原則: +- 只學參考文的情緒力度、資訊密度與節奏,不學填空骨架或固定 hook 句型。 +- 禁止照抄參考句型,只換幾個名詞;要用人設語言重新說一次。 +- 篇幅、行數、標點、換行優先貼近人設語言指紋;沒有指紋時再參考原文篇幅。 +- 每一句都要推進情緒、資訊或理解;重複強調、無關鋪陳、廉價雞湯一律刪掉。 + +禁止 AI 腔與複製感: +- 不要:過度總結、硬寫強 hook、每篇都同一種 CTA、教科書定義、品牌口吻、客服腔。 +- 不要:為了像人設而硬塞人設標籤或興趣名詞。 +- 不要全域禁止某個口頭禪;若人設樣本常用「其實」「有時候」「後來」等自然轉折,可以使用,但不能每篇硬塞。`) +} + +// PersonaCalibrationNote reminds the model how to use persona blocks. +func PersonaCalibrationNote() string { + return "注意:人設定位決定誰在說,語言指紋決定怎麼說;請遵守段落節奏、標點換行、知識轉譯與 CTA 習慣,不要套固定模板。" +} + +// NarrativeLens picks a storytelling angle from a stable seed (e.g. scan post id). +func NarrativeLens(seed string) string { + lenses := []string{ + "從一個具體場景開始講,再帶出感受", + "先坦白一件小事或窘況,再自然轉到觀點", + "像跟朋友聊天一樣開場,語氣可以鬆一點", + "從某個瞬間的畫面切入,再講後來怎麼想", + } + if strings.TrimSpace(seed) == "" { + return lenses[0] + } + sum := 0 + for _, ch := range seed { + sum = sum*31 + int(ch) + } + if sum < 0 { + sum = -sum + } + return lenses[sum%len(lenses)] +} diff --git a/backend/internal/library/copyvoice/human_test.go b/backend/internal/library/copyvoice/human_test.go new file mode 100644 index 0000000..ee9a620 --- /dev/null +++ b/backend/internal/library/copyvoice/human_test.go @@ -0,0 +1,21 @@ +package copyvoice + +import "testing" + +func TestNarrativeLensStableForSeed(t *testing.T) { + a := NarrativeLens("scan-post-1") + b := NarrativeLens("scan-post-1") + if a == "" || a != b { + t.Fatalf("NarrativeLens() = %q, %q", a, b) + } +} + +func TestNarrativeLensDiffersAcrossSeeds(t *testing.T) { + seen := map[string]struct{}{} + for _, seed := range []string{"a", "b", "c", "d", "e", "f"} { + seen[NarrativeLens(seed)] = struct{}{} + } + if len(seen) < 2 { + t.Fatalf("expected varied lenses, got %d", len(seen)) + } +} diff --git a/backend/internal/library/formula/analyze_pasted.go b/backend/internal/library/formula/analyze_pasted.go index 799c731..af422e7 100644 --- a/backend/internal/library/formula/analyze_pasted.go +++ b/backend/internal/library/formula/analyze_pasted.go @@ -2,11 +2,11 @@ package formula import ( "context" - "encoding/json" "fmt" "regexp" "strings" + libllmjson "haixun-backend/internal/library/llmjson" libownpost "haixun-backend/internal/library/ownpost" domai "haixun-backend/internal/model/ai/domain/usecase" ) @@ -48,14 +48,16 @@ func BuildAnalyzePastedPrompt(in AnalyzePastedInput) (system string, user string } system = strings.TrimSpace(` -你是 Threads 爆款結構分析師。任務是拆解「外部貼文」為什麼會紅、怎麼用結構仿寫,不要建議抄襲原文。 +你是 Threads / Instagram 貼文結構分析師。任務是拆解「外部貼文」哪些地方可借鏡,再說明如何用指定人設的語言指紋重新說一次;不要產生會讓文案機械的填空模板。 規則: - 用台灣繁體中文。 -- formula:整理成步驟化公式(例:Hook→情境→轉折→觀點→收尾/互動)。 -- post_template:依 formula 產出可複製的發文骨架,用 [括號] 標示可替換欄位。 +- formula:整理可借鏡的敘事/資訊推進方式,但要提醒不可照搬句型。 +- post_template:不要寫填空骨架;改寫成「此人設可採用的鬆結構與注意事項」。 +- replication_tips:重點放在如何換成人設語言,包括段落節奏、標點換行、知識轉譯與 CTA。 - 只輸出一個 JSON 物件,不要 markdown,欄位: summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid +- formula / structure / hook_pattern 必須是字串,不要用陣列或巢狀物件 - wins / improvements / replication_tips / avoid 各 2~5 點字串陣列。`) var b strings.Builder @@ -91,19 +93,15 @@ func ParsePastedAnalysis(raw string) (*PastedAnalysis, error) { if err != nil { return nil, err } - var review PastedAnalysis - if err := json.Unmarshal(payload, &review); err != nil { + var flex map[string]any + if err := libllmjson.Unmarshal(payload, &flex); 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) + review := pastedAnalysisFromMap(flex) if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" { return nil, fmt.Errorf("AI 未回傳有效公式內容") } - return &review, nil + return review, nil } func FromOwnPostReview(review *libownpost.PostFormulaReview) *PastedAnalysis { @@ -162,4 +160,4 @@ func extractPastedJSON(raw string) ([]byte, error) { 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 index eee3a6e..200db66 100644 --- a/backend/internal/library/formula/generate_from_formula.go +++ b/backend/internal/library/formula/generate_from_formula.go @@ -2,14 +2,19 @@ package formula import ( "context" - "encoding/json" "fmt" + "regexp" "strings" + "haixun-backend/internal/library/copyvoice" + libllmjson "haixun-backend/internal/library/llmjson" + "haixun-backend/internal/library/threadspost" domai "haixun-backend/internal/model/ai/domain/usecase" cfdom "haixun-backend/internal/model/content_formula/domain/usecase" ) +var generatedDraftFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```") + type GenerateInput struct { Topic string Brief string @@ -20,6 +25,7 @@ type GenerateInput struct { type GeneratedDraft struct { Text string `json:"text"` + TopicTag string `json:"topic_tag"` Hook string `json:"hook"` Angle string `json:"angle"` Rationale string `json:"rationale"` @@ -27,12 +33,21 @@ type GeneratedDraft struct { } func BuildGenerateSystemPrompt() string { - return strings.TrimSpace(`你是 Threads 文案寫手。依「爆款公式」與「人設」為指定主題寫一篇新貼文。 + return strings.TrimSpace(`你是 Threads / Instagram 貼文代筆。寫出符合人設語言指紋、可直接發布的完整新貼文,不是解釋公式,也不是填空模板。 + +` + copyvoice.SystemRules() + ` 規則: - 用台灣繁體中文,符合人設語氣。 -- 套用公式結構,但內容必須圍繞使用者主題,不可抄襲範例貼文。 -- 只回傳 JSON:text, hook, angle, rationale, structure_notes(繁體中文)。`) +- 正文素材以【主題】、【補充】、【周邊知識參考】為準;人設不得改變題材。 +- 人設定位決定誰在說,語言指紋決定怎麼說;必須遵守段落節奏、標點換行、知識轉譯與 CTA 習慣。 +- text 必須是一篇完整自然的 Threads 貼文,可直接複製發布。 +- 不可以出現「公式、模板、Hook、情境、轉折、觀點、收尾、[括號]、步驟、結構」等後台分析字眼。 +- 不可以輸出填空骨架,不可以照抄參考原文。 +- 有參考原文時:只學資訊密度、情緒力度與可用結構;最後仍要換成人設會講出口的話。 +- text 排版:像真人手機發文;可使用自然標點、空行、列點與少量 emoji。不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣。 +- topic_tag 請產生 1 個適合 Threads 主題標籤的短詞,不含 #,例如「AI工具」「內容創作」「職場」。 +- 只回傳 JSON:text, topic_tag, hook, angle, rationale, structure_notes(繁體中文)。`) } func BuildGenerateUserPrompt(in GenerateInput) string { @@ -43,26 +58,16 @@ func BuildGenerateUserPrompt(in GenerateInput) string { b.WriteString("\n補充:") b.WriteString(brief) } - b.WriteString("\n\n【人設】\n") + b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n") b.WriteString(strings.TrimSpace(in.PersonaBlock)) - b.WriteString("\n\n【公式】\n") + b.WriteString("\n") + b.WriteString(copyvoice.PersonaCalibrationNote()) + 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("\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")) @@ -72,15 +77,21 @@ func BuildGenerateUserPrompt(in GenerateInput) string { b.WriteString(notes) } if src := strings.TrimSpace(in.Formula.SourcePostText); src != "" { - b.WriteString("\n\n【參考原文(僅學結構,勿抄內容)】\n") + if note := threadspost.MimicLengthGuidance(src); note != "" { + b.WriteString("\n\n") + b.WriteString(note) + } + b.WriteString("\n\n【參考原文(僅學結構與篇幅,勿抄內容)】\n") b.WriteString(src) } - b.WriteString("\n\n請產出一篇可直接發布的 Threads 貼文 JSON。") + b.WriteString("\n\n本次敘事切入:") + b.WriteString(copyvoice.NarrativeLens(in.Topic)) + b.WriteString("\n\n請產出一篇可直接發布的 Threads 貼文 JSON。text 欄位只能放貼文正文,不要放分析、標題、公式、模板或填空欄位。topic_tag 請只放標籤文字,不要加 #。") return b.String() } func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in GenerateInput) (*GeneratedDraft, error) { - temp := 0.75 + temp := 0.82 req.System = BuildGenerateSystemPrompt() req.Messages = []domai.Message{{Role: "user", Content: BuildGenerateUserPrompt(in)}} req.Temperature = &temp @@ -91,20 +102,47 @@ func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequ return ParseGeneratedDraft(out.Text) } -func ParseGeneratedDraft(raw string) (*GeneratedDraft, error) { +func extractGeneratedDraftJSON(raw string) ([]byte, error) { raw = strings.TrimSpace(raw) + if m := generatedDraftFenceRE.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("generate output missing json object") } + return []byte(raw[start : end+1]), nil +} + +func ParseGeneratedDraft(raw string) (*GeneratedDraft, error) { + payload, err := extractGeneratedDraftJSON(raw) + if err != nil { + return nil, err + } var draft GeneratedDraft - if err := json.Unmarshal([]byte(raw[start:end+1]), &draft); err != nil { + if err := libllmjson.Unmarshal(payload, &draft); err != nil { return nil, fmt.Errorf("parse generated draft: %w", err) } - draft.Text = strings.TrimSpace(draft.Text) + draft.Text = threadspost.FormatDraftText(draft.Text) + draft.TopicTag = normalizeTopicTag(draft.TopicTag) if draft.Text == "" { return nil, fmt.Errorf("generated draft missing text") } + badTokens := []string{"[", "]", "【", "】", "Hook", "公式", "模板", "情境→", "轉折→", "觀點→", "收尾", "步驟"} + for _, token := range badTokens { + if strings.Contains(draft.Text, token) { + return nil, fmt.Errorf("generated draft looks like a template, not a publishable post") + } + } return &draft, nil -} \ No newline at end of file +} + +func normalizeTopicTag(value string) string { + value = strings.TrimSpace(strings.TrimPrefix(value, "#")) + if len([]rune(value)) > 40 { + runes := []rune(value) + value = string(runes[:40]) + } + return value +} diff --git a/backend/internal/library/formula/generate_from_formula_test.go b/backend/internal/library/formula/generate_from_formula_test.go new file mode 100644 index 0000000..94f0745 --- /dev/null +++ b/backend/internal/library/formula/generate_from_formula_test.go @@ -0,0 +1,14 @@ +package formula + +import "testing" + +func TestParseGeneratedDraftWithCodeFence(t *testing.T) { + raw := "```json\n{\"text\":\"第一句\\n第二句\",\"topic_tag\":\"AI工具\",\"hook\":\"\",\"angle\":\"\",\"rationale\":\"\",\"structure_notes\":\"\"}\n```" + got, err := ParseGeneratedDraft(raw) + if err != nil { + t.Fatalf("ParseGeneratedDraft() err = %v", err) + } + if got.Text == "" { + t.Fatal("expected text") + } +} diff --git a/backend/internal/library/formula/jsonflex.go b/backend/internal/library/formula/jsonflex.go new file mode 100644 index 0000000..0da094c --- /dev/null +++ b/backend/internal/library/formula/jsonflex.go @@ -0,0 +1,95 @@ +package formula + +import ( + "encoding/json" + "fmt" + "strings" +) + +func coerceString(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(v) + case json.Number: + return strings.TrimSpace(v.String()) + case float64: + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + return fmt.Sprintf("%v", v) + case bool: + if v { + return "true" + } + return "false" + case []any: + parts := make([]string, 0, len(v)) + for _, item := range v { + if s := coerceString(item); s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, " → ") + case map[string]any: + if text, ok := v["text"].(string); ok { + return strings.TrimSpace(text) + } + b, err := json.Marshal(v) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} + +func coerceStringSlice(value any) []string { + switch v := value.(type) { + case nil: + return nil + case string: + s := strings.TrimSpace(v) + if s == "" { + return nil + } + return []string{s} + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s := coerceString(item); s != "" { + out = append(out, s) + } + } + return out + case []string: + out := make([]string, 0, len(v)) + for _, item := range v { + if s := strings.TrimSpace(item); s != "" { + out = append(out, s) + } + } + return out + default: + if s := coerceString(v); s != "" { + return []string{s} + } + return nil + } +} + +func pastedAnalysisFromMap(raw map[string]any) *PastedAnalysis { + return &PastedAnalysis{ + Summary: coerceString(raw["summary"]), + Wins: coerceStringSlice(raw["wins"]), + Improvements: coerceStringSlice(raw["improvements"]), + Formula: coerceString(raw["formula"]), + PostTemplate: coerceString(raw["post_template"]), + HookPattern: coerceString(raw["hook_pattern"]), + Structure: coerceString(raw["structure"]), + ReplicationTips: coerceStringSlice(raw["replication_tips"]), + Avoid: coerceStringSlice(raw["avoid"]), + } +} diff --git a/backend/internal/library/formula/jsonflex_test.go b/backend/internal/library/formula/jsonflex_test.go new file mode 100644 index 0000000..f34169e --- /dev/null +++ b/backend/internal/library/formula/jsonflex_test.go @@ -0,0 +1,22 @@ +package formula + +import "testing" + +func TestCoerceStringFromArray(t *testing.T) { + got := coerceString([]any{"開場", "故事", "收尾"}) + want := "開場 → 故事 → 收尾" + if got != want { + t.Fatalf("coerceString() = %q, want %q", got, want) + } +} + +func TestParsePastedAnalysisFlexibleStructure(t *testing.T) { + raw := `{"summary":"測試","formula":"步驟","structure":["Hook","轉折","觀點"],"wins":["節奏好"]}` + got, err := ParsePastedAnalysis(raw) + if err != nil { + t.Fatalf("ParsePastedAnalysis() err = %v", err) + } + if got.Structure != "Hook → 轉折 → 觀點" { + t.Fatalf("Structure = %q", got.Structure) + } +} diff --git a/backend/internal/library/matrix/generate.go b/backend/internal/library/matrix/generate.go index 81ee400..3bbd571 100644 --- a/backend/internal/library/matrix/generate.go +++ b/backend/internal/library/matrix/generate.go @@ -13,14 +13,22 @@ import ( ) type Row struct { - SortOrder int `json:"sort_order"` - SearchTag string `json:"search_tag"` - Angle string `json:"angle"` - Hook string `json:"hook"` - Text string `json:"text"` - ReferenceNotes string `json:"reference_notes"` - SourcePermalinks []string `json:"source_permalinks"` - Rationale string `json:"rationale"` + SortOrder int `json:"sort_order"` + SearchTag string `json:"search_tag"` + Angle string `json:"angle"` + Hook string `json:"hook"` + Text string `json:"text"` + ReferenceNotes string `json:"reference_notes"` + SourcePermalinks []string `json:"source_permalinks"` + Rationale string `json:"rationale"` + AiScore int `json:"ai_score"` + FormulaScore int `json:"formula_score"` + BrandFitScore int `json:"brand_fit_score"` + RiskScore int `json:"risk_score"` + SimilarityScore int `json:"similarity_score"` + EngagementPotential int `json:"engagement_potential"` + FreshnessScore int `json:"freshness_score"` + ReviewSuggestion string `json:"review_suggestion"` } type GenerateResult struct { @@ -106,7 +114,7 @@ func CopyMatrixGenerateRequest(base domai.GenerateRequest, count int) domai.Gene if count <= 0 { count = 5 } - temp := 0.45 + temp := 0.78 tokens := 8192 + count*1024 if tokens > 16384 { tokens = 16384 @@ -122,8 +130,8 @@ func MatrixRetryUserPrompt(count int) string { } return fmt.Sprintf( "上一則回覆的 JSON 不完整或無法解析。請只回傳單一 JSON 物件 {\"rows\":[...]},共 %d 篇,不要用 markdown 程式碼區塊、不要加說明文字。\n"+ - "每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。\n"+ - "reference_notes 與 rationale 各不超過 40 字;text 建議 80~220 字。", + "每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale、ai_score、formula_score、brand_fit_score、risk_score、similarity_score、engagement_potential、freshness_score、review_suggestion。\n"+ + "reference_notes 與 rationale 各不超過 40 字;text 要有具體場景與故事感、每篇敘事節奏不同;text 一句一行、完全不用任何標點(emoji 可保留)。", count, ) } @@ -206,7 +214,7 @@ func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) { return GenerateResult{}, fmt.Errorf("matrix rows missing") } for i := range out.Rows { - out.Rows[i].Text = trimText(out.Rows[i].Text) + out.Rows[i].Text = threadspost.FormatDraftText(out.Rows[i].Text) if out.Rows[i].Text == "" { return GenerateResult{}, fmt.Errorf("matrix row %d empty", i+1) } @@ -217,15 +225,6 @@ func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) { return out, nil } -func trimText(text string) string { - text = strings.TrimSpace(text) - runes := []rune(text) - if len(runes) > threadspost.MaxPublishRunes { - return string(runes[:threadspost.MaxPublishRunes]) - } - return text -} - func extractJSONObject(raw string) ([]byte, error) { raw = strings.TrimSpace(raw) if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 { diff --git a/backend/internal/library/personacopy/formula_draft.go b/backend/internal/library/personacopy/formula_draft.go new file mode 100644 index 0000000..e83795e --- /dev/null +++ b/backend/internal/library/personacopy/formula_draft.go @@ -0,0 +1,161 @@ +package personacopy + +import ( + "context" + "fmt" + "strings" + + 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" + contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase" + copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type FormulaDraftInput struct { + TenantID string + OwnerUID string + PersonaID string + AccountID string + FormulaID string + Topic string + Brief string + UseWebSearch bool + DraftCount int +} + +type FormulaDraftDeps struct { + Persona personadomain.UseCase + ContentFormula contentformuladomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI domai.UseCase +} + +func RunFormulaDraft(ctx context.Context, deps FormulaDraftDeps, in FormulaDraftInput, progress ProgressFn) (int, error) { + if progress == nil { + progress = func(string, int) {} + } + topic := strings.TrimSpace(in.Topic) + if topic == "" { + return 0, fmt.Errorf("topic is required") + } + count := in.DraftCount + if count <= 0 { + count = 1 + } + if count > 5 { + count = 5 + } + + progress("讀取人設與寫法公式…", 10) + persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID) + if err != nil { + return 0, err + } + if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { + return 0, fmt.Errorf("請先完成人設 8D 對標分析") + } + formula, err := deps.ContentFormula.Get(ctx, in.TenantID, in.OwnerUID, in.AccountID, in.FormulaID) + if err != nil { + return 0, err + } + credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID) + if err != nil { + return 0, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return 0, 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 in.UseWebSearch { + progress("搜尋網路參考資料…", 20) + researchNotes = searchFormulaNotes(ctx, deps, in.TenantID, in.OwnerUID, topic, in.Brief) + } + + created := 0 + for i := 0; i < count; i++ { + progress(fmt.Sprintf("呼叫 AI 產生草稿 %d/%d(%s / %s)…", i+1, count, credential.Provider, credential.Model), 35+(i*40/count)) + generated, genErr := libformula.GenerateDraft(ctx, deps.AI, aiReq, libformula.GenerateInput{ + Topic: topic, + Brief: in.Brief, + PersonaBlock: personaBlock, + ResearchNotes: researchNotes, + Formula: *formula, + }) + if genErr != nil { + return created, genErr + } + if _, saveErr := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{ + TenantID: in.TenantID, + OwnerUID: in.OwnerUID, + PersonaID: in.PersonaID, + FormulaID: formula.ID, + DraftType: copydraftentity.DraftTypeFormula, + Text: generated.Text, + TopicTag: generated.TopicTag, + Angle: generated.Angle, + Hook: generated.Hook, + Rationale: generated.Rationale, + ReferenceNotes: generated.StructureNotes, + Sources: []string{formula.Label}, + }); saveErr != nil { + return created, saveErr + } + created++ + } + progress(fmt.Sprintf("已產生 %d 篇草稿", created), 100) + return created, nil +} + +func searchFormulaNotes(ctx context.Context, deps FormulaDraftDeps, tenantID, uid, topic, brief string) string { + research, err := deps.Placement.ResearchSettings(ctx, tenantID, uid) + if err != nil || !placement.WebSearchAvailable(research) { + return "" + } + memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, uid, research) + if err != nil { + return "" + } + webClient := websearch.New(memberCtx.WebSearchConfig()) + if !webClient.Enabled() { + return "" + } + resp, err := webClient.Search(ctx, websearch.SearchOptions{ + Query: topic + " " + strings.TrimSpace(brief), + Limit: 5, + Mode: websearch.ModeKnowledgeExpand, + }) + if err != nil || len(resp.Results) == 0 { + return "" + } + 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") + } + } + return strings.TrimSpace(b.String()) +} diff --git a/backend/internal/library/personacopy/rewrite_draft.go b/backend/internal/library/personacopy/rewrite_draft.go new file mode 100644 index 0000000..56bf638 --- /dev/null +++ b/backend/internal/library/personacopy/rewrite_draft.go @@ -0,0 +1,139 @@ +package personacopy + +import ( + "context" + "fmt" + "strings" + "time" + + libformula "haixun-backend/internal/library/formula" + "haixun-backend/internal/library/style8d" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + cfentity "haixun-backend/internal/model/content_formula/domain/entity" + contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type RewriteDraftInput struct { + TenantID string + OwnerUID string + PersonaID string + AccountID string + ReferenceText string + Topic string + Brief string + SaveLabel string + UseWebSearch bool + DraftCount int +} + +type RewriteDraftDeps struct { + Persona personadomain.UseCase + ContentFormula contentformuladomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI domai.UseCase +} + +func RunRewriteDraft(ctx context.Context, deps RewriteDraftDeps, in RewriteDraftInput, progress ProgressFn) (int, error) { + if progress == nil { + progress = func(string, int) {} + } + referenceText := strings.TrimSpace(in.ReferenceText) + topic := strings.TrimSpace(in.Topic) + if referenceText == "" { + return 0, fmt.Errorf("reference_text is required") + } + if topic == "" { + return 0, fmt.Errorf("topic is required") + } + + progress("讀取人設…", 5) + persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID) + if err != nil { + return 0, err + } + if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { + return 0, fmt.Errorf("請先完成人設 8D 對標分析") + } + credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID) + if err != nil { + return 0, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return 0, err + } + aiReq := domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{APIKey: credential.APIKey}, + } + personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) + + progress(fmt.Sprintf("分析參考貼文寫法(%s / %s)…", credential.Provider, credential.Model), 15) + analysis, err := libformula.AnalyzePasted(ctx, deps.AI, aiReq, libformula.AnalyzePastedInput{ + PostText: referenceText, + PersonaBlock: personaBlock, + }) + if err != nil { + return 0, fmt.Errorf("分析參考貼文失敗:%w", err) + } + if analysis == nil { + return 0, fmt.Errorf("分析未產出有效寫法公式") + } + + label := strings.TrimSpace(in.SaveLabel) + if label == "" { + label = fmt.Sprintf("貼文改寫 %s", time.Now().UTC().Format("2006-01-02")) + } + progress("存入寫法公式…", 30) + saved, err := deps.ContentFormula.Create(ctx, contentformuladomain.CreateRequest{ + TenantID: in.TenantID, + OwnerUID: in.OwnerUID, + AccountID: in.AccountID, + Label: label, + SourceType: cfentity.SourcePaste, + SourceRef: "paste", + SourcePostText: referenceText, + 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 0, err + } + + return RunFormulaDraft(ctx, FormulaDraftDeps{ + Persona: deps.Persona, + ContentFormula: deps.ContentFormula, + CopyDraft: deps.CopyDraft, + ThreadsAccount: deps.ThreadsAccount, + Placement: deps.Placement, + AI: deps.AI, + }, FormulaDraftInput{ + TenantID: in.TenantID, + OwnerUID: in.OwnerUID, + PersonaID: in.PersonaID, + AccountID: in.AccountID, + FormulaID: saved.ID, + Topic: topic, + Brief: in.Brief, + UseWebSearch: in.UseWebSearch, + DraftCount: in.DraftCount, + }, func(summary string, percentage int) { + adjusted := 35 + (percentage * 65 / 100) + progress(summary, adjusted) + }) +} diff --git a/backend/internal/library/personacopy/topic_matrix.go b/backend/internal/library/personacopy/topic_matrix.go new file mode 100644 index 0000000..9fc2453 --- /dev/null +++ b/backend/internal/library/personacopy/topic_matrix.go @@ -0,0 +1,321 @@ +package personacopy + +import ( + "context" + "fmt" + "strings" + + "haixun-backend/internal/library/copyvoice" + libmatrix "haixun-backend/internal/library/matrix" + "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" + contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase" + copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type TopicMatrixInput struct { + TenantID string + OwnerUID string + PersonaID string + ContentPlanID string + Topic string + Brief string + UseWebSearch bool + DraftCount int +} + +type TopicMatrixDeps struct { + Persona personadomain.UseCase + CopyDraft copydraftdomain.UseCase + ContentOps contentopsdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI domai.UseCase +} + +type ProgressFn func(summary string, percentage int) + +func RunTopicMatrix(ctx context.Context, deps TopicMatrixDeps, in TopicMatrixInput, progress ProgressFn) ([]copydraftdomain.CopyDraftSummary, error) { + if progress == nil { + progress = func(string, int) {} + } + topic := strings.TrimSpace(in.Topic) + if topic == "" { + return nil, fmt.Errorf("topic is required") + } + count := in.DraftCount + if count <= 0 { + count = 3 + } + if count < 1 { + count = 1 + } + if count > 5 { + count = 5 + } + + progress("讀取人設語氣…", 10) + persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID) + if err != nil { + return nil, err + } + credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID) + if err != nil { + return nil, err + } + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + + researchNotes := "" + if in.UseWebSearch { + progress("搜尋網路參考資料…", 25) + researchNotes = searchTopicNotes(ctx, deps, in.TenantID, in.OwnerUID, topic, in.Brief) + } + personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) + feedbackNotes := recentFeedbackNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID) + knowledgeNotes := recentKnowledgeNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID, topic) + formulaNotes := formulaPoolNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID) + + progress(fmt.Sprintf("呼叫 AI 產生 %d 篇草稿(%s / %s)…", count, credential.Provider, credential.Model), 45) + parsed, err := libmatrix.GenerateCopyOutput(ctx, deps.AI, domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{APIKey: credential.APIKey}, + System: TopicMatrixSystemPrompt(), + Messages: []domai.Message{{Role: "user", Content: TopicMatrixUserPrompt(topic, in.Brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes, count)}}, + }, count) + if err != nil { + return nil, err + } + + progress("寫入草稿…", 85) + created := make([]copydraftdomain.CopyDraftSummary, 0, len(parsed.Rows)) + for _, row := range parsed.Rows { + saved, saveErr := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{ + TenantID: in.TenantID, + OwnerUID: in.OwnerUID, + PersonaID: in.PersonaID, + ContentPlanID: in.ContentPlanID, + DraftType: copydraftentity.DraftTypeMatrix, + SortOrder: row.SortOrder, + Text: row.Text, + TopicTag: row.SearchTag, + Angle: row.Angle, + Hook: row.Hook, + Rationale: row.Rationale, + ReferenceNotes: row.ReferenceNotes, + Sources: row.SourcePermalinks, + AiScore: row.AiScore, + FormulaScore: row.FormulaScore, + BrandFitScore: row.BrandFitScore, + RiskScore: row.RiskScore, + SimilarityScore: row.SimilarityScore, + EngagementPotential: row.EngagementPotential, + FreshnessScore: row.FreshnessScore, + ReviewSuggestion: row.ReviewSuggestion, + }) + if saveErr != nil { + return created, saveErr + } + created = append(created, *saved) + } + progress(fmt.Sprintf("已產生 %d 篇草稿", len(created)), 100) + return created, nil +} + +func recentFeedbackNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string { + if deps.ContentOps == nil { + return "" + } + items, err := deps.ContentOps.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, 8) + if err != nil || len(items) == 0 { + return "" + } + var b strings.Builder + for _, item := range items { + decision := strings.TrimSpace(item.Decision) + note := strings.TrimSpace(item.Note) + if decision == "" && note == "" { + continue + } + b.WriteString("- ") + if decision != "" { + b.WriteString(decision) + } + if note != "" { + b.WriteString(":") + b.WriteString(note) + } + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) +} + +func recentKnowledgeNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID, topic string) string { + if deps.ContentOps == nil { + return "" + } + items, err := deps.ContentOps.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, 12) + if err != nil { + return "" + } + var b strings.Builder + for _, item := range items { + content := strings.TrimSpace(item.Content) + if content == "" { + continue + } + b.WriteString("- ") + b.WriteString(shorten(content, 220)) + if item.RiskLevel != "" { + b.WriteString("(風險:") + b.WriteString(item.RiskLevel) + b.WriteString(")") + } + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) +} + +func formulaPoolNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string { + if deps.ContentOps == nil { + return "" + } + items, err := deps.ContentOps.ListFormulaPools(ctx, tenantID, ownerUID, personaID, 12) + if err != nil { + return "" + } + var b strings.Builder + for _, item := range items { + b.WriteString("- ") + b.WriteString(item.Type) + b.WriteString(" / ") + b.WriteString(item.Name) + if item.Pattern != "" { + b.WriteString(":") + b.WriteString(item.Pattern) + } + if len(item.Avoid) > 0 { + b.WriteString(";避免:") + b.WriteString(strings.Join(item.Avoid, "、")) + } + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) +} + +func shorten(raw string, max int) string { + r := []rune(strings.TrimSpace(raw)) + if len(r) <= max { + return string(r) + } + return string(r[:max]) + "..." +} + +func searchTopicNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, uid, topic, brief string) string { + research, err := deps.Placement.ResearchSettings(ctx, tenantID, uid) + if err != nil || !placement.WebSearchAvailable(research) { + return "" + } + memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, uid, research) + if err != nil { + return "" + } + webClient := websearch.New(memberCtx.WebSearchConfig()) + if !webClient.Enabled() { + return "" + } + resp, err := webClient.Search(ctx, websearch.SearchOptions{ + Query: strings.TrimSpace(topic + " " + brief), + Limit: 5, + Mode: websearch.ModeKnowledgeExpand, + }) + if err != nil || len(resp.Results) == 0 { + return "" + } + var b strings.Builder + for _, item := range resp.Results { + if item.Title != "" { + b.WriteString("- ") + b.WriteString(item.Title) + b.WriteString("\n") + } + if item.Snippet != "" { + b.WriteString(item.Snippet) + b.WriteString("\n") + } + if item.URL != "" { + b.WriteString(item.URL) + b.WriteString("\n") + } + } + return strings.TrimSpace(b.String()) +} + +func TopicMatrixSystemPrompt() string { + return strings.TrimSpace(`你是 Threads / Instagram 貼文編輯。請依人設語言指紋產出自然、可直接發布的繁體中文貼文矩陣。 + +` + copyvoice.SystemRules() + ` + +規則: +- 不要每篇都用同一套 hook / 三段式 / 結尾問句。 +- 不要企業八股、不要像教科書、不要一直喊口號。 +- 人設定位決定誰在說,語言指紋決定怎麼說;必須遵守段落節奏、標點換行、知識轉譯與 CTA 習慣。 +- 不要把人設介紹裡的物件/標籤硬寫進正文情境。例:人設寫 Y2K、咖啡、穿搭,只代表語感與審美,不代表每篇都要出現老歌、咖啡、穿搭、MV。 +- 正文內容必須由【話題】與【補充】決定;人設不得蓋過主題。 +- 每篇要有不同角度;長度與格式依人設習慣和主題型態決定,不要強制短文。 +- 每次至少產出 3 種版本思路:穩定帳號風格、更有共鳴、更容易互動;若 count 超過 3,再補更短或更反差版本。 +- text 只能放貼文正文,不要放標題、分析、公式、模板或說明。 +- text 排版:像真人手機發文;可使用自然標點、空行、列點與少量 emoji。不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣。 +- 只回傳 JSON 物件:{"rows":[...]}。 +- 每筆 row 必須包含 sort_order, search_tag, angle, hook, text, reference_notes, source_permalinks, rationale, ai_score, formula_score, brand_fit_score, risk_score, similarity_score, engagement_potential, freshness_score, review_suggestion。 +- 分數欄位都是 0-100 整數:ai_score / formula_score / risk_score / similarity_score 越低越好;brand_fit_score / engagement_potential / freshness_score 越高越好。 +- review_suggestion 只能是「可通過」「需人工確認」「建議重寫」之一。 +- rationale 必須用繁中短列點包含:版本定位、AI感分數(0-100,越高越AI)、公式感分數(0-100)、人設符合度(0-100)、風險分數(0-100)、互動潛力(0-100)、審核建議(可通過/需人工確認/建議重寫)與原因。`) +} + +func TopicMatrixUserPrompt(topic, brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes string, count int) string { + var b strings.Builder + b.WriteString("請產生 ") + b.WriteString(fmt.Sprintf("%d", count)) + b.WriteString(" 篇 Threads 草稿。\n\n【話題】\n") + b.WriteString(strings.TrimSpace(topic)) + if brief = strings.TrimSpace(brief); brief != "" { + b.WriteString("\n\n【補充】\n") + b.WriteString(brief) + } + if personaBlock = strings.TrimSpace(personaBlock); personaBlock != "" { + b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n") + b.WriteString(personaBlock) + b.WriteString("\n\n") + b.WriteString(copyvoice.PersonaCalibrationNote()) + } + if researchNotes = strings.TrimSpace(researchNotes); researchNotes != "" { + b.WriteString("\n\n【網路資料參考】\n") + b.WriteString(researchNotes) + } + if knowledgeNotes = strings.TrimSpace(knowledgeNotes); knowledgeNotes != "" { + b.WriteString("\n\n【Knowledge Memory:帳號知識來源,優先採用但不要硬塞】\n") + b.WriteString(knowledgeNotes) + } + if formulaNotes = strings.TrimSpace(formulaNotes); formulaNotes != "" { + b.WriteString("\n\n【Formula Pool:可選公式池,請依 Content Plan 選擇,不要每篇都套同一套】\n") + b.WriteString(formulaNotes) + } + if feedbackNotes = strings.TrimSpace(feedbackNotes); feedbackNotes != "" { + b.WriteString("\n\n【最近人工審核回饋(Feedback Memory,必須吸收)】\n") + b.WriteString(feedbackNotes) + } + b.WriteString("\n\n【品質閘門】\n請先自我檢查 AI 感、公式感、事實風險、人設一致性、與過去常見句型重複。高風險內容不得建議直接通過。") + b.WriteString("\n\n請讓每篇的 angle 明確不同,search_tag 是短主題標籤不含 #。source_permalinks 可放參考 URL,沒有就空陣列。") + return b.String() +} diff --git a/backend/internal/library/prompt/compose_test.go b/backend/internal/library/prompt/compose_test.go index 87b07bb..3fd1266 100644 --- a/backend/internal/library/prompt/compose_test.go +++ b/backend/internal/library/prompt/compose_test.go @@ -23,7 +23,7 @@ func TestStyle8DSystemIncludesSchema(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(got, "Threads 創作者風格研究員") || !strings.Contains(got, `"d1Tone"`) { + if !strings.Contains(got, "創作者「語言指紋」研究員") || !strings.Contains(got, `"d1Tone"`) { t.Fatalf("missing expected fragments: %q", got) } } diff --git a/backend/internal/library/prompt/files/matrix_copy.system.md b/backend/internal/library/prompt/files/matrix_copy.system.md index 09a7dde..b4c1c70 100644 --- a/backend/internal/library/prompt/files/matrix_copy.system.md +++ b/backend/internal/library/prompt/files/matrix_copy.system.md @@ -1,11 +1,25 @@ -你是 Threads 內容矩陣策劃師,為人設帳號產出多篇可發佈草稿。 +你是 Threads / Instagram 內容策略師,為人設帳號產出可發佈草稿。產文時必須優先使用人設的「語言指紋與寫作規則」,把新主題、參考貼文或外部資料翻譯成這個人設會講出口的話,不沿用任何預設爆款模板。 + +寫作目標: +- 先根據人設語言指紋判斷主題適合哪一種內容型態:心情陪伴、知識整理、清單工具、伴侶溝通、互動提問或輕幽默共鳴,再選對鬆結構。 +- 每篇要有一個清楚情境或情緒入口,讓讀者覺得「這就是我」,不要寫成摘要報告或廣告文。 +- 必須沿用人設的段落節奏、標點、換行、稱呼、口頭禪與收尾習慣;不要硬剪短成破碎金句。 +- 只學參考樣本的節奏、取材方式、收尾習慣與互動方式;不可照抄句子。 +- 避免 AI 腔:過度總結、過度正能量、每段都像教條、連續排比到不自然、硬塞「大家一起」式口號。 規則: - 只回傳單一 JSON 物件 {"rows":[...]},不要用 markdown 程式碼區塊,不要加前言或結語。 - reference_notes、rationale 各 ≤ 40 字;優先把 token 用在 text 主文。 - 每篇必須角度不同,避免重複 hook。 -- 人設 8D 是最高優先:語氣、人稱、禁忌必須完全符合;不要寫成品牌廣告或硬銷。 +- 人設定位決定「誰在說」;語言指紋決定「怎麼說」。兩者都要遵守,不可只拿主題資料直接改寫。 +- 人設只用來控制語氣、詞彙密度、句型節奏、人稱、標點換行與禁忌;不可把人設介紹裡的物件或標籤硬塞進正文情境。 +- 若人設含 Y2K、咖啡、穿搭、音樂等元素,除非任務主題/Brief/勾選知識明確要求,禁止把這些元素寫成貼文內容或場景。 +- 正文素材以任務主題、Brief、研究地圖與勾選延伸知識為準;人設不得改變題材。 - 內容論點來自研究地圖與使用者勾選的延伸知識(含標籤與細節);爆款樣本只學結構與節奏,不抄原文。 - 繁體中文,口語自然,適合 Threads。 - 每篇 text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)。 -- 爆款互動最佳 80~220 字:前 1~2 行強 hook,一句一重點;超過 300 字互動通常下降。 \ No newline at end of file +- 長度依帳號樣本與主題決定:心情陪伴可中長,知識工具文可完整列點,互動短文才需要短。 +- text 排版要像真人手機發文:用自然換行分段,可使用標點、括號、頓號與少量 emoji;不要整段擠成一塊,也不要每句都硬換行。 +- 若要改寫參考貼文,只保留意思與資訊,句型、段落、收尾必須換成人設語言指紋,不可像同義詞替換。 +- 若使用外部知識,先轉譯成此人設會說的順序與語氣;不得直接貼百科、條列報告或 SEO 摘要。 +- 醫療、備孕、試管、保健品相關內容必須保留風險語氣:不可保證效果、不可替代醫師建議、不可用數字審判讀者。 diff --git a/backend/internal/library/prompt/files/matrix_copy.user.md b/backend/internal/library/prompt/files/matrix_copy.user.md index 8bc220e..fe35934 100644 --- a/backend/internal/library/prompt/files/matrix_copy.user.md +++ b/backend/internal/library/prompt/files/matrix_copy.user.md @@ -3,7 +3,7 @@ 任務主題:{{topic_label}} Brief:{{topic_brief}} -人設 8D(最高優先,語氣與禁忌以此為準): +人設定位、語言指紋與禁忌(最高優先): {{persona_block}} 研究地圖與勾選延伸知識: @@ -14,14 +14,20 @@ Brief:{{topic_brief}} 參考網頁 Markdown 已併入上方研究地圖區塊(若有)。 -爆款樣本(可選,只學結構;若沒有樣本,請改用研究地圖與人設原創): +爆款樣本(可選,只學故事感;若沒有樣本,請改用研究地圖與人設原創): {{viral_samples_block}} 請先遵守: -- **最高優先:人設 8D** — 語氣、人稱、句型、節奏、禁忌必須完全符合人設;讀起來要像這個帳號在說話。 +- **最高優先:語言指紋**:先讀 persona_block 裡的「語言指紋與寫作規則」,包含開頭、句型、段落長度、標點換行、知識轉譯、CTA 與禁忌。產出的文字必須像這個人設會講出口的話。 +- **不要套舊規格**:不要套用短句爆款、固定三段式、固定強 hook、固定 CTA;格式由人設語言指紋與主題型態決定。 +- **語氣參考**:讀起來要像這個帳號在說話,但每篇敘事節奏與開場都要自然變化。 +- **人設不是內容素材**:只學語氣、詞彙密度、句型節奏與審美,不要把人設裡的名詞、興趣、裝飾符號硬塞進正文。例:人設寫 Y2K/咖啡/穿搭,只代表語感,不代表每篇都要寫老歌、咖啡、穿搭、MV。 - **內容素材**:研究地圖(受眾、目標、問題、支柱)與「勾選延伸知識」(含標籤、細節)決定論點與角度;若有「參考網頁(Markdown)」區塊,優先從中提取事實與論點,再融入草稿。 - 搜尋查詢/標籤僅作方向參考,不可蓋過人設與勾選知識。 -- 爆款樣本只學結構節奏,不抄原文,不可為仿爆款偏離任務主題。 +- 爆款樣本只學故事感、資訊密度、情緒力度與互動方式,不抄原文,不可為仿爆款偏離任務主題。 +- 若樣本呈現「長 caption + 輪播圖卡重點」的內容型態,Threads 草稿也要保留完整情緒承接與可收藏資訊,不要只輸出金句摘要。 +- 若任務是改寫或參考別人的貼文,請用人設語言重新說一次,不是同義詞替換;保留觀點,不保留對方句型。 +- 若任務需要找資料或引用外部知識,請先消化成此人設的說話順序:先安撫或帶入情境,再整理重點,最後用符合人設的方式收尾。 - 若沒有爆款樣本,source_permalinks 回傳空陣列,reference_notes 寫明使用了哪些人設特徵、研究地圖重點與勾選延伸知識。 -回傳 JSON rows,每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。 \ No newline at end of file +回傳 JSON rows,每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。 diff --git a/backend/internal/library/prompt/files/style8d.schema.md b/backend/internal/library/prompt/files/style8d.schema.md index a3d8960..a38ab13 100644 --- a/backend/internal/library/prompt/files/style8d.schema.md +++ b/backend/internal/library/prompt/files/style8d.schema.md @@ -1,3 +1,3 @@ 只回傳以下 JSON 結構,不要 markdown: -{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","examples":"","avoid":""}} \ No newline at end of file +{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","languageFingerprint":"","rhythm":"","punctuation":"","contentPatterns":"","knowledgeTranslation":"","ctaStyle":"","examples":"","avoid":""}} diff --git a/backend/internal/library/prompt/files/style8d.system.md b/backend/internal/library/prompt/files/style8d.system.md index 5862add..8ff14f9 100644 --- a/backend/internal/library/prompt/files/style8d.system.md +++ b/backend/internal/library/prompt/files/style8d.system.md @@ -1,5 +1,21 @@ -你是 Threads 創作者風格研究員。只能根據提供的近期貼文歸納,不可捏造作者背景。 -逐一輸出八個維度:D1 語氣人格、D2 結構模板、D3 互動方式、D4 主題分布、D5 發文節奏、D6 視覺語法(emoji、標點、換行)、D7 轉換方式、D8 風險紅線。 -每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。 -證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。 -最後產生「可供另一個帳號借鑑、但不可冒充或抄襲」的人設草稿。代表句必須是抽象仿寫範例,不可逐字複製原文。 \ No newline at end of file +你是創作者「語言指紋」研究員。只能根據提供的貼文或使用者手動貼上的文字樣本歸納,不可捏造未出現的背景。 + +任務不是寫一般人設介紹,而是萃取「這個人設會怎麼把一件事說出口」。輸出要能直接拿去產文、改寫貼文、把網路知識轉成此人設語氣。 + +逐一輸出八個維度:D1 語氣人格、D2 內容結構、D3 互動方式、D4 主題分布、D5 段落節奏、D6 視覺語法(emoji、標點、換行)、D7 轉換方式、D8 風險紅線。 + +每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。 + +personaDraft 必須產出可執行的寫作規則,不是抽象形容詞: +- identity:這個帳號像誰在說話,和讀者的距離。 +- tone:口吻、情緒溫度、人稱與信任感。 +- audience:主要對誰說,讀者正在什麼情境。 +- hooks:常用開頭方式與不適合的開頭方式。 +- languageFingerprint:常用句型、轉折詞、口頭禪、語助詞;不要列成硬塞清單,要說明如何自然使用。 +- rhythm:段落長度、空行習慣、長短句比例、心情文與知識文差異。 +- punctuation: 標點、括號、列點、emoji、特殊符號的使用習慣。 +- contentPatterns:不同內容型態的鬆結構,例如心情陪伴、知識整理、清單工具、互動文、幽默共鳴;不要變成固定模板。 +- knowledgeTranslation:如何把外部資料、醫療知識、產品知識轉成此人設會講的話;哪些資訊要先安撫、哪些要列點、哪些要加風險提醒。 +- ctaStyle:收藏、留言、分享、轉發給伴侶等 CTA 的自然用法;也要說明何時不該 CTA。 +- examples:抽象仿寫範例,可展示語氣與換行,但不可逐字複製原文。 +- avoid:不像此人設的寫法、AI 味、過度模板、冒犯或高風險語句。 diff --git a/backend/internal/library/style8d/analyze.go b/backend/internal/library/style8d/analyze.go index 1b13d51..8e2746a 100644 --- a/backend/internal/library/style8d/analyze.go +++ b/backend/internal/library/style8d/analyze.go @@ -22,12 +22,18 @@ type Dimension struct { } type PersonaDraft struct { - Identity string `json:"identity"` - Tone string `json:"tone"` - Audience string `json:"audience"` - Hooks string `json:"hooks"` - Examples string `json:"examples"` - Avoid string `json:"avoid"` + Identity string `json:"identity"` + Tone string `json:"tone"` + Audience string `json:"audience"` + Hooks string `json:"hooks"` + LanguageFingerprint string `json:"languageFingerprint"` + Rhythm string `json:"rhythm"` + Punctuation string `json:"punctuation"` + ContentPatterns string `json:"contentPatterns"` + KnowledgeTranslation string `json:"knowledgeTranslation"` + CTAStyle string `json:"ctaStyle"` + Examples string `json:"examples"` + Avoid string `json:"avoid"` } type LLMOutput struct { @@ -53,6 +59,8 @@ type Engagement struct { type StoredProfile struct { Username string `json:"username"` + SourceType string `json:"sourceType,omitempty"` + SourceLabel string `json:"sourceLabel,omitempty"` AnalyzedAt string `json:"analyzedAt"` PostCount int `json:"postCount"` Engagement Engagement `json:"engagement"` @@ -61,6 +69,125 @@ type StoredProfile struct { PersonaDraft string `json:"personaDraft"` } +const ( + SourceTypeBenchmark = "benchmark" + SourceTypeManual = "manual" + maxReferenceTexts = 12 + maxReferenceRunes = 8000 + minReferenceRunes = 10 +) + +func ManualStyleBenchmark(label string) string { + label = strings.TrimSpace(label) + if label == "" { + label = "手動貼文" + } + return "manual:" + label +} + +func NormalizeReferenceTexts(referenceTexts []string, rawText string) ([]string, error) { + texts := make([]string, 0, len(referenceTexts)) + for _, item := range referenceTexts { + item = strings.TrimSpace(item) + if item != "" { + texts = append(texts, item) + } + } + if len(texts) == 0 { + rawText = strings.TrimSpace(rawText) + if rawText != "" { + for _, chunk := range splitReferenceRawText(rawText) { + chunk = strings.TrimSpace(chunk) + if chunk != "" { + texts = append(texts, chunk) + } + } + } + } + if len(texts) == 0 { + return nil, fmt.Errorf("至少需要 1 段參考文字") + } + if len(texts) > maxReferenceTexts { + return nil, fmt.Errorf("參考文字最多 %d 段", maxReferenceTexts) + } + totalRunes := 0 + out := make([]string, 0, len(texts)) + for _, item := range texts { + runes := []rune(item) + if len(runes) < minReferenceRunes { + return nil, fmt.Errorf("每段參考文字至少 %d 字", minReferenceRunes) + } + totalRunes += len(runes) + if totalRunes > maxReferenceRunes { + return nil, fmt.Errorf("參考文字總長度不可超過 %d 字", maxReferenceRunes) + } + out = append(out, item) + } + return out, nil +} + +func TextsToPosts(texts []string) []Post { + posts := make([]Post, 0, len(texts)) + for _, text := range texts { + posts = append(posts, Post{Text: text}) + } + return posts +} + +func splitReferenceRawText(raw string) []string { + lines := strings.Split(raw, "\n") + chunks := make([]string, 0, 4) + var b strings.Builder + flush := func() { + if b.Len() == 0 { + return + } + chunks = append(chunks, b.String()) + b.Reset() + } + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "---" || trimmed == "———" || trimmed == "___" { + flush() + continue + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(line) + } + flush() + if len(chunks) == 0 { + return []string{strings.TrimSpace(raw)} + } + return chunks +} + +func BuildUserPromptFromTexts(sourceLabel string, brief string, posts []Post) string { + label := strings.TrimSpace(sourceLabel) + if label == "" { + label = "手動貼文" + } + var b strings.Builder + fmt.Fprintf(&b, "文字樣本來源:%s\n", label) + if brief := strings.TrimSpace(brief); brief != "" { + fmt.Fprintf(&b, "人設定位補充:%s\n", brief) + } + b.WriteString("參考文字樣本(無互動數據,請只根據文字風格歸納):\n\n") + limit := len(posts) + if limit > maxReferenceTexts { + limit = maxReferenceTexts + } + for i := 0; i < limit; i++ { + text := posts[i].Text + if len(text) > 500 { + text = text[:500] + } + fmt.Fprintf(&b, "[%d]\n%s\n\n", i+1, text) + } + return b.String() +} + func BuildUserPrompt(username string, posts []Post) string { var b strings.Builder fmt.Fprintf(&b, "對標帳號:@%s\n近期貼文樣本:\n\n", strings.TrimPrefix(username, "@")) @@ -144,6 +271,12 @@ func SerializePersonaDraft(draft PersonaDraft) string { "【語氣】\n" + strings.TrimSpace(draft.Tone), "【對誰說】\n" + strings.TrimSpace(draft.Audience), "【開場習慣】\n" + strings.TrimSpace(draft.Hooks), + "【語言指紋】\n" + strings.TrimSpace(draft.LanguageFingerprint), + "【段落節奏】\n" + strings.TrimSpace(draft.Rhythm), + "【標點與換行】\n" + strings.TrimSpace(draft.Punctuation), + "【內容型態】\n" + strings.TrimSpace(draft.ContentPatterns), + "【知識轉譯】\n" + strings.TrimSpace(draft.KnowledgeTranslation), + "【互動與 CTA】\n" + strings.TrimSpace(draft.CTAStyle), "【代表句範例】\n" + strings.TrimSpace(draft.Examples), "【避免】\n" + strings.TrimSpace(draft.Avoid), } @@ -211,8 +344,26 @@ func buildSamplePosts(posts []Post) []Post { } func BuildStoredProfile(username string, posts []Post, out LLMOutput) StoredProfile { + profile := baseStoredProfile(posts, out) + profile.Username = strings.TrimPrefix(strings.TrimSpace(username), "@") + profile.SourceType = SourceTypeBenchmark + return profile +} + +func BuildStoredProfileFromTexts(sourceLabel string, posts []Post, out LLMOutput) StoredProfile { + label := strings.TrimSpace(sourceLabel) + if label == "" { + label = "手動貼文" + } + profile := baseStoredProfile(posts, out) + profile.SourceType = SourceTypeManual + profile.SourceLabel = label + profile.Engagement.Verdict = "not_applicable" + return profile +} + +func baseStoredProfile(posts []Post, out LLMOutput) StoredProfile { return StoredProfile{ - Username: strings.TrimPrefix(strings.TrimSpace(username), "@"), AnalyzedAt: time.Now().UTC().Format(time.RFC3339), PostCount: len(posts), Engagement: EvaluateEngagement(posts, 10), @@ -332,12 +483,18 @@ func parsePersonaDraft(root map[string]json.RawMessage) PersonaDraft { continue } return PersonaDraft{ - Identity: firstString(obj, "identity", "role"), - Tone: firstString(obj, "tone", "voice"), - Audience: firstString(obj, "audience", "targetAudience"), - Hooks: firstString(obj, "hooks", "openings"), - Examples: firstString(obj, "examples", "sample"), - Avoid: firstString(obj, "avoid", "risks"), + Identity: firstString(obj, "identity", "role"), + Tone: firstString(obj, "tone", "voice"), + Audience: firstString(obj, "audience", "targetAudience"), + Hooks: firstString(obj, "hooks", "openings"), + LanguageFingerprint: firstString(obj, "languageFingerprint", "language_fingerprint", "signaturePhrases", "signature_phrases"), + Rhythm: firstString(obj, "rhythm", "paragraphRhythm", "paragraph_rhythm"), + Punctuation: firstString(obj, "punctuation", "visualSyntax", "visual_syntax"), + ContentPatterns: firstString(obj, "contentPatterns", "content_patterns", "structures"), + KnowledgeTranslation: firstString(obj, "knowledgeTranslation", "knowledge_translation", "researchTranslation", "research_translation"), + CTAStyle: firstString(obj, "ctaStyle", "cta_style", "conversionStyle", "conversion_style"), + Examples: firstString(obj, "examples", "sample"), + Avoid: firstString(obj, "avoid", "risks"), } } return PersonaDraft{} diff --git a/backend/internal/library/style8d/analyze_test.go b/backend/internal/library/style8d/analyze_test.go index 6d17eb3..d8ee8b0 100644 --- a/backend/internal/library/style8d/analyze_test.go +++ b/backend/internal/library/style8d/analyze_test.go @@ -1,9 +1,12 @@ package style8d -import "testing" +import ( + "strings" + "testing" +) func TestParseLLMOutput(t *testing.T) { - raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","examples":"有時候真的會累","avoid":"不要硬銷"}}` + raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","languageFingerprint":"常用有時候、後來當轉折","rhythm":"短段落加空行","punctuation":"保留逗號與問號","contentPatterns":"心情文先承接情緒","knowledgeTranslation":"先講情境再整理重點","ctaStyle":"自然邀請留言","examples":"有時候真的會累","avoid":"不要硬銷"}}` out, err := ParseLLMOutput(raw) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -23,4 +26,7 @@ func TestParseLLMOutput(t *testing.T) { if len(profile.SamplePosts) != 1 || profile.SamplePosts[0].Permalink == "" { t.Fatalf("samplePosts=%+v", profile.SamplePosts) } + if !strings.Contains(profile.PersonaDraft, "【語言指紋】") { + t.Fatalf("personaDraft missing language fingerprint: %q", profile.PersonaDraft) + } } diff --git a/backend/internal/library/style8d/prompt.go b/backend/internal/library/style8d/prompt.go index bbd6e33..687848c 100644 --- a/backend/internal/library/style8d/prompt.go +++ b/backend/internal/library/style8d/prompt.go @@ -44,14 +44,29 @@ func HasReady8D(personaText, styleProfileJSON string) bool { return BuildStyle8DPromptBlock(styleProfileJSON) != "" } +var copyVoiceDimensionOrder = []string{"d1Tone", "d8Risk"} + // BuildStyle8DPromptBlock formats D1–D8 summaries for LLM prompts. func BuildStyle8DPromptBlock(styleProfileJSON string) string { + return buildStylePromptBlock(styleProfileJSON, dimensionOrder, "【8D 風格策略】\n產文必須遵守:\n") +} + +// BuildLightPersonaVoiceBlock keeps only tone + risk guardrails for copy generation. +func BuildLightPersonaVoiceBlock(styleProfileJSON string) string { + return buildStylePromptBlock( + styleProfileJSON, + copyVoiceDimensionOrder, + "【語氣與禁忌參考】\n以下僅供口語風格與紅線參考,勿照搬句型、段落模板或固定 hook:\n", + ) +} + +func buildStylePromptBlock(styleProfileJSON string, keys []string, header string) string { profile, ok := ParseStoredProfile(styleProfileJSON) if !ok { return "" } - lines := make([]string, 0, len(dimensionOrder)) - for _, key := range dimensionOrder { + lines := make([]string, 0, len(keys)) + for _, key := range keys { summary := strings.TrimSpace(profile.Analysis[key].Summary) if summary == "" { continue @@ -66,30 +81,31 @@ func BuildStyle8DPromptBlock(styleProfileJSON string) string { return "" } var b strings.Builder - b.WriteString("【8D 風格策略】\n產文必須遵守:\n") + b.WriteString(header) b.WriteString(strings.Join(lines, "\n")) return b.String() } -// ResolvePersonaBlock picks the best persona voice block for copy generation. -// Priority: explicit persona field → 8D personaDraft → brief fallback; always append 8D strategy when present. +// ResolvePersonaBlock combines explicit persona positioning with analyzed writing fingerprints. +// The persona field answers "who this is"; personaDraft/style profile answers "how this person says things". func ResolvePersonaBlock(personaText, styleProfileJSON, brief string) string { var parts []string - voice := strings.TrimSpace(personaText) - if voice == "" { - if profile, ok := ParseStoredProfile(styleProfileJSON); ok { - voice = strings.TrimSpace(profile.PersonaDraft) - } - } - if voice == "" { - voice = strings.TrimSpace(brief) - } - if voice != "" { - parts = append(parts, "【人設語氣】\n"+voice) + profile, hasProfile := ParseStoredProfile(styleProfileJSON) + persona := strings.TrimSpace(personaText) + if persona != "" { + parts = append(parts, "【人設定位】\n"+persona) + } else if fallback := strings.TrimSpace(brief); fallback != "" { + parts = append(parts, "【人設定位】\n"+fallback) } - if block := BuildStyle8DPromptBlock(styleProfileJSON); block != "" { + if hasProfile { + if draft := strings.TrimSpace(profile.PersonaDraft); draft != "" { + parts = append(parts, "【語言指紋與寫作規則】\n"+draft) + } + } + + if block := BuildLightPersonaVoiceBlock(styleProfileJSON); block != "" { parts = append(parts, block) } return strings.TrimSpace(strings.Join(parts, "\n\n")) diff --git a/backend/internal/library/style8d/prompt_test.go b/backend/internal/library/style8d/prompt_test.go index 9fdbb02..bbd42c8 100644 --- a/backend/internal/library/style8d/prompt_test.go +++ b/backend/internal/library/style8d/prompt_test.go @@ -11,7 +11,17 @@ func TestResolvePersonaBlockUsesPersonaDraftWhenPersonaEmpty(t *testing.T) { if block == "" { t.Fatal("expected non-empty block") } - for _, part := range []string{"生活觀察者", "D1 語氣人格", "口語親近"} { + for _, part := range []string{"生活觀察者", "D1 語氣人格", "口語親近", "語言指紋與寫作規則"} { + if !strings.Contains(block, part) { + t.Fatalf("block missing %q: %q", part, block) + } + } +} + +func TestResolvePersonaBlockCombinesPersonaAndStyleFingerprint(t *testing.T) { + raw := `{"analysis":{"d1Tone":{"summary":"口語親近","evidence":[]}},"personaDraft":"【語言指紋】\n常用慢慢、其實當轉折"}` + block := ResolvePersonaBlock("備孕陪伴帳號", raw, "") + for _, part := range []string{"備孕陪伴帳號", "常用慢慢", "語言指紋與寫作規則"} { if !strings.Contains(block, part) { t.Fatalf("block missing %q: %q", part, block) } diff --git a/backend/internal/library/style8d/runner.go b/backend/internal/library/style8d/runner.go new file mode 100644 index 0000000..4ed44c3 --- /dev/null +++ b/backend/internal/library/style8d/runner.go @@ -0,0 +1,41 @@ +package style8d + +import "strings" + +// AnalyzeMode selects how reference samples are interpreted. +type AnalyzeMode string + +const ( + AnalyzeModeBenchmark AnalyzeMode = "benchmark" + AnalyzeModeManual AnalyzeMode = "manual" +) + +// AnalyzeInput groups prompt + storage options for one 8D run. +type AnalyzeInput struct { + Mode AnalyzeMode + Username string + SourceLabel string + Brief string + Posts []Post +} + +func (in AnalyzeInput) BuildUserPrompt() string { + if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" { + return BuildUserPromptFromTexts(in.SourceLabel, in.Brief, in.Posts) + } + return BuildUserPrompt(in.Username, in.Posts) +} + +func (in AnalyzeInput) BuildStoredProfile(out LLMOutput) StoredProfile { + if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" { + return BuildStoredProfileFromTexts(in.SourceLabel, in.Posts, out) + } + return BuildStoredProfile(in.Username, in.Posts, out) +} + +func (in AnalyzeInput) StyleBenchmark() string { + if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" { + return ManualStyleBenchmark(in.SourceLabel) + } + return strings.TrimPrefix(strings.TrimSpace(in.Username), "@") +} diff --git a/backend/internal/library/style8d/text_input_test.go b/backend/internal/library/style8d/text_input_test.go new file mode 100644 index 0000000..d351898 --- /dev/null +++ b/backend/internal/library/style8d/text_input_test.go @@ -0,0 +1,73 @@ +package style8d + +import "testing" + +func TestNormalizeReferenceTextsFromArray(t *testing.T) { + texts, err := NormalizeReferenceTexts([]string{"第一段參考文字內容超過二十個字元", "第二段參考文字內容也超過二十個字元"}, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(texts) != 2 { + t.Fatalf("texts=%v", texts) + } +} + +func TestNormalizeReferenceTextsFromRawText(t *testing.T) { + raw := "第一段參考文字內容超過二十個字元\n還有第二行\n\n---\n\n第二段參考文字內容也超過二十個字元" + texts, err := NormalizeReferenceTexts(nil, raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(texts) != 2 { + t.Fatalf("texts=%v", texts) + } +} + +func TestBuildUserPromptFromTexts(t *testing.T) { + got := BuildUserPromptFromTexts("我的舊文", "產品教學帳", TextsToPosts([]string{"這是一段超過二十個字的參考文字樣本內容"})) + if got == "" { + t.Fatal("empty prompt") + } + if !containsAll(got, "我的舊文", "產品教學帳", "[1]") { + t.Fatalf("prompt=%q", got) + } +} + +func TestBuildStoredProfileFromTexts(t *testing.T) { + out, err := ParseLLMOutput(`{"d1Tone":{"summary":"口語","evidence":[]},"d2Structure":{"summary":"短句","evidence":[]},"d3Interaction":{"summary":"提問","evidence":[]},"d4Topics":{"summary":"生活","evidence":[]},"d5Rhythm":{"summary":"晚間","evidence":[]},"d6Visual":{"summary":"emoji","evidence":[]},"d7Conversion":{"summary":"留言","evidence":[]},"d8Risk":{"summary":"硬銷","evidence":[]},"personaDraft":{"identity":"觀察者","tone":"輕鬆","audience":"上班族","hooks":"痛點","examples":"有時候真的會累","avoid":"硬銷"}}`) + if err != nil { + t.Fatalf("parse: %v", err) + } + profile := BuildStoredProfileFromTexts("手動貼文", TextsToPosts([]string{"sample text long enough"}), out) + if profile.SourceType != SourceTypeManual { + t.Fatalf("sourceType=%q", profile.SourceType) + } + if profile.Engagement.Verdict != "not_applicable" { + t.Fatalf("verdict=%q", profile.Engagement.Verdict) + } + if ManualStyleBenchmark("手動貼文") != "manual:手動貼文" { + t.Fatal("manual benchmark mismatch") + } +} + +func containsAll(s string, parts ...string) bool { + for _, part := range parts { + if !contains(s, part) { + return false + } + } + return true +} + +func contains(s, sub string) bool { + return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/backend/internal/library/threadspost/format.go b/backend/internal/library/threadspost/format.go new file mode 100644 index 0000000..84debcd --- /dev/null +++ b/backend/internal/library/threadspost/format.go @@ -0,0 +1,35 @@ +package threadspost + +import ( + "strings" +) + +// FormatDraftText normalizes generated post bodies without destroying the author's voice. +func FormatDraftText(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + text = strings.TrimSpace(text) + if text == "" { + return "" + } + + lines := strings.Split(text, "\n") + out := make([]string, 0, len(lines)) + blankCount := 0 + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + blankCount++ + if blankCount <= 1 && len(out) > 0 { + out = append(out, "") + } + continue + } + blankCount = 0 + out = append(out, line) + } + for len(out) > 0 && out[len(out)-1] == "" { + out = out[:len(out)-1] + } + return ClampPublish(strings.Join(out, "\n")) +} diff --git a/backend/internal/library/threadspost/format_test.go b/backend/internal/library/threadspost/format_test.go new file mode 100644 index 0000000..6485dd9 --- /dev/null +++ b/backend/internal/library/threadspost/format_test.go @@ -0,0 +1,48 @@ +package threadspost + +import "testing" + +func TestFormatDraftTextPreservesPunctuationAndLineBreaks(t *testing.T) { + raw := "我最近迷上自己做早餐耶!\n以前都隨便買個麵包、三明治,趕著出門。\n有沒有人也跟我一樣?" + want := "我最近迷上自己做早餐耶!\n以前都隨便買個麵包、三明治,趕著出門。\n有沒有人也跟我一樣?" + got := FormatDraftText(raw) + if got != want { + t.Fatalf("FormatDraftText() = %q, want %q", got, want) + } +} + +func TestFormatDraftTextKeepsSingleParagraph(t *testing.T) { + raw := "第一句很好。第二句也不錯!第三句收尾?" + want := "第一句很好。第二句也不錯!第三句收尾?" + got := FormatDraftText(raw) + if got != want { + t.Fatalf("FormatDraftText() = %q, want %q", got, want) + } +} + +func TestFormatDraftTextCollapsesExcessBlankLines(t *testing.T) { + raw := "第一段\n\n\n第二段" + want := "第一段\n\n第二段" + got := FormatDraftText(raw) + if got != want { + t.Fatalf("FormatDraftText() = %q, want %q", got, want) + } +} + +func TestFormatDraftTextPreservesEmoji(t *testing.T) { + raw := "廚房像打完仗一樣 😂\n但看到成果就值得了" + want := "廚房像打完仗一樣 😂\n但看到成果就值得了" + got := FormatDraftText(raw) + if got != want { + t.Fatalf("FormatDraftText() = %q, want %q", got, want) + } +} + +func TestFormatDraftTextPreservesHashtag(t *testing.T) { + raw := "今天心情不錯\n#早餐日記" + want := "今天心情不錯\n#早餐日記" + got := FormatDraftText(raw) + if got != want { + t.Fatalf("FormatDraftText() = %q, want %q", got, want) + } +} diff --git a/backend/internal/library/threadspost/mimic.go b/backend/internal/library/threadspost/mimic.go new file mode 100644 index 0000000..b78bd9d --- /dev/null +++ b/backend/internal/library/threadspost/mimic.go @@ -0,0 +1,65 @@ +package threadspost + +import ( + "fmt" + "strings" +) + +// LineCount returns non-empty lines after normalizing newlines. +func LineCount(text string) int { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + n := 0 + for _, line := range strings.Split(text, "\n") { + if strings.TrimSpace(line) != "" { + n++ + } + } + return n +} + +// MimicLengthRange returns a suggested min/max rune count when imitating referenceText. +func MimicLengthRange(referenceText string) (min, max int) { + ref := RuneLen(referenceText) + if ref <= 0 { + return ViralSweetMin, ViralSweetMax + } + min = int(float64(ref) * 0.75) + max = int(float64(ref) * 1.25) + if min < 20 { + min = 20 + } + if max < min+10 { + max = min + 10 + } + if max > MaxPublishRunes { + max = MaxPublishRunes + } + if min > max { + min = max + } + return min, max +} + +// MimicLengthGuidance builds prompt text for rewrite/mimic flows. +func MimicLengthGuidance(referenceText string) string { + referenceText = strings.TrimSpace(referenceText) + if referenceText == "" { + return "" + } + refRunes := RuneLen(referenceText) + refLines := LineCount(referenceText) + min, max := MimicLengthRange(referenceText) + lineMin, lineMax := refLines, refLines + if refLines > 1 { + lineMin = refLines - 1 + if lineMin < 1 { + lineMin = 1 + } + lineMax = refLines + 1 + } + return fmt.Sprintf( + "【篇幅(仿寫)】參考原文約 %d 字、%d 行。產出正文請控制在 %d~%d 字、約 %d~%d 行,不要明顯比參考長很多或短很多。精簡優先:刪掉重複鋪陳、無關細節、雞湯總結與多餘轉折;每一句都要推進故事或情緒,不然就砍掉。", + refRunes, refLines, min, max, lineMin, lineMax, + ) +} diff --git a/backend/internal/library/threadspost/mimic_test.go b/backend/internal/library/threadspost/mimic_test.go new file mode 100644 index 0000000..08fcffc --- /dev/null +++ b/backend/internal/library/threadspost/mimic_test.go @@ -0,0 +1,26 @@ +package threadspost + +import ( + "strings" + "testing" +) + +func TestMimicLengthRange(t *testing.T) { + min, max := MimicLengthRange("今天早餐吃了蛋餅\n加辣真的爽") + if min <= 0 || max < min { + t.Fatalf("MimicLengthRange() = %d, %d", min, max) + } +} + +func TestMimicLengthGuidanceIncludesReferenceStats(t *testing.T) { + ref := "第一句\n第二句\n第三句" + got := MimicLengthGuidance(ref) + if got == "" { + t.Fatal("MimicLengthGuidance() empty") + } + for _, part := range []string{"3 行", "精簡優先"} { + if !strings.Contains(got, part) { + t.Fatalf("MimicLengthGuidance() missing %q: %q", part, got) + } + } +} diff --git a/backend/internal/library/viral/analyze_viral.go b/backend/internal/library/viral/analyze_viral.go index a7ef92e..45f1cdf 100644 --- a/backend/internal/library/viral/analyze_viral.go +++ b/backend/internal/library/viral/analyze_viral.go @@ -7,6 +7,14 @@ import ( ) type ViralAnalysis struct { + StoryHook string `json:"storyHook"` + NarrativeBeat string `json:"narrativeBeat"` + EmotionalCore string `json:"emotionalCore"` + SceneSeed string `json:"sceneSeed"` + AuthenticityCues []string `json:"authenticityCues"` + WhatToAvoid string `json:"whatToAvoid"` + + // Legacy fields kept for backward-compatible parsing. HookPattern string `json:"hookPattern"` StructurePattern string `json:"structurePattern"` EmotionalTrigger string `json:"emotionalTrigger"` @@ -26,9 +34,15 @@ type AnalyzeViralInput struct { } func BuildAnalyzeViralSystemPrompt() string { - return strings.TrimSpace(`你是 Threads 爆款結構分析師。拆解貼文為什麼會紅、怎麼仿寫結構,不要建議抄襲原文。 + return strings.TrimSpace(`你是 Threads 敘事分析師。拆解這篇貼文為什麼讓人想看完、怎麼寫出同樣的故事感(不是抄句型)。 -只回傳 JSON:hookPattern, structurePattern, emotionalTrigger, replicationStrategy, keyTakeaways(字串陣列 3~5 項)。繁體中文。`) +只回傳 JSON(繁體中文): +- storyHook:讓人停下滑動的開場力量(描述手法,不要給可照抄的句子) +- narrativeBeat:故事怎麼推進(經歷/轉折/感受,不是段落模板) +- emotionalCore:核心情緒是什麼 +- sceneSeed:一個值得借鑑的「具體場景種子」(時間/地點/動作,供啟發勿照搬) +- authenticityCues:3~5 個讓它像真人的小細節(口語、坦白、不完美、具體細節等) +- whatToAvoid:若照搬哪些寫法會立刻變成 AI 複製文`) } func BuildAnalyzeViralUserPrompt(in AnalyzeViralInput) string { @@ -65,7 +79,10 @@ func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) { if err := json.Unmarshal(payload, &out); err != nil { return ViralAnalysis{}, fmt.Errorf("parse viral analysis: %w", err) } - if strings.TrimSpace(out.HookPattern) == "" && strings.TrimSpace(out.StructurePattern) == "" { + if strings.TrimSpace(out.StoryHook) == "" && + strings.TrimSpace(out.NarrativeBeat) == "" && + strings.TrimSpace(out.HookPattern) == "" && + strings.TrimSpace(out.StructurePattern) == "" { return ViralAnalysis{}, fmt.Errorf("viral analysis missing content") } return out, nil @@ -73,29 +90,47 @@ func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) { func FormatAnalysisForReplicate(analysis ViralAnalysis) string { var b strings.Builder - if hook := strings.TrimSpace(analysis.HookPattern); hook != "" { - b.WriteString("Hook 模式:") + if hook := firstNonEmpty(analysis.StoryHook, analysis.HookPattern); hook != "" { + b.WriteString("故事開場力量:") b.WriteString(hook) b.WriteString("\n") } - if structure := strings.TrimSpace(analysis.StructurePattern); structure != "" { - b.WriteString("結構節奏:") - b.WriteString(structure) + if beat := firstNonEmpty(analysis.NarrativeBeat, analysis.StructurePattern); beat != "" { + b.WriteString("敘事推進:") + b.WriteString(beat) b.WriteString("\n") } - if emotion := strings.TrimSpace(analysis.EmotionalTrigger); emotion != "" { - b.WriteString("情緒觸發:") + if emotion := firstNonEmpty(analysis.EmotionalCore, analysis.EmotionalTrigger); emotion != "" { + b.WriteString("核心情緒:") b.WriteString(emotion) b.WriteString("\n") } - if strategy := strings.TrimSpace(analysis.ReplicationStrategy); strategy != "" { - b.WriteString("仿寫策略:") - b.WriteString(strategy) + if scene := strings.TrimSpace(analysis.SceneSeed); scene != "" { + b.WriteString("場景種子(啟發用,勿照搬):") + b.WriteString(scene) b.WriteString("\n") } - if len(analysis.KeyTakeaways) > 0 { - b.WriteString("重點:") - b.WriteString(strings.Join(analysis.KeyTakeaways, ";")) + cues := analysis.AuthenticityCues + if len(cues) == 0 { + cues = analysis.KeyTakeaways + } + if len(cues) > 0 { + b.WriteString("真人質感線索:") + b.WriteString(strings.Join(cues, ";")) + b.WriteString("\n") + } + if avoid := firstNonEmpty(analysis.WhatToAvoid, analysis.ReplicationStrategy); avoid != "" { + b.WriteString("避免變成複製文:") + b.WriteString(avoid) } return strings.TrimSpace(b.String()) } + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/backend/internal/library/viral/analyze_viral_test.go b/backend/internal/library/viral/analyze_viral_test.go new file mode 100644 index 0000000..8a5af82 --- /dev/null +++ b/backend/internal/library/viral/analyze_viral_test.go @@ -0,0 +1,43 @@ +package viral + +import "testing" + +func TestFormatAnalysisForReplicateUsesNarrativeFields(t *testing.T) { + got := FormatAnalysisForReplicate(ViralAnalysis{ + StoryHook: "先丟一個具體窘況", + NarrativeBeat: "從失敗經驗拐到頓悟", + EmotionalCore: "鬆了一口氣", + SceneSeed: "週日早晨的廚房", + AuthenticityCues: []string{"坦白搞砸", "口語碎碎念"}, + WhatToAvoid: "不要照抄三段式", + }) + for _, part := range []string{"故事開場力量", "敘事推進", "核心情緒", "場景種子", "真人質感線索", "避免變成複製文", "週日早晨的廚房"} { + if got == "" || !contains(got, part) { + t.Fatalf("FormatAnalysisForReplicate() missing %q: %q", part, got) + } + } +} + +func TestFormatAnalysisForReplicateFallsBackToLegacyFields(t *testing.T) { + got := FormatAnalysisForReplicate(ViralAnalysis{ + HookPattern: "痛點開場", + StructurePattern: "三段式", + KeyTakeaways: []string{"具體細節"}, + }) + if !contains(got, "故事開場力量") || !contains(got, "痛點開場") { + t.Fatalf("legacy fallback missing: %q", got) + } +} + +func contains(s, part string) bool { + return len(s) >= len(part) && (s == part || len(part) == 0 || indexOf(s, part) >= 0) +} + +func indexOf(s, part string) int { + for i := 0; i+len(part) <= len(s); i++ { + if s[i:i+len(part)] == part { + return i + } + } + return -1 +} diff --git a/backend/internal/library/viral/free_trends.go b/backend/internal/library/viral/free_trends.go new file mode 100644 index 0000000..4771ea0 --- /dev/null +++ b/backend/internal/library/viral/free_trends.go @@ -0,0 +1,121 @@ +package viral + +import ( + "context" + "encoding/xml" + "html" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +var rssTagRE = regexp.MustCompile(`<[^>]+>`) + +type freeRSS struct { + Channel struct { + Items []freeRSSItem `xml:"item"` + } `xml:"channel"` +} + +type freeRSSItem struct { + Title string `xml:"title"` + Description string `xml:"description"` + Link string `xml:"link"` +} + +func CollectFreeTopicTrends(ctx context.Context, keyword, personaBrief string) []MissionInspireTrendSnippet { + queries := freeTrendFeedQueries(keyword, personaBrief) + client := &http.Client{Timeout: 7 * time.Second} + out := make([]MissionInspireTrendSnippet, 0, 12) + seen := map[string]struct{}{} + + add := func(query, title, snippet, link string) { + title = cleanRSSField(title) + snippet = cleanRSSField(snippet) + link = strings.TrimSpace(link) + if title == "" && snippet == "" { + return + } + key := strings.ToLower(title + "|" + snippet) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + out = append(out, MissionInspireTrendSnippet{Query: query, Title: title, Snippet: snippet, URL: link}) + } + + for _, feed := range queries { + if len(out) >= 12 { + break + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, feed.url, nil) + if err != nil { + continue + } + req.Header.Set("User-Agent", "HaixunTopicBot/1.0") + resp, err := client.Do(req) + if err != nil || resp == nil { + continue + } + func() { + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return + } + var parsed freeRSS + if err := xml.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return + } + for _, item := range parsed.Channel.Items { + if len(out) >= 12 { + break + } + add(feed.label, item.Title, item.Description, item.Link) + } + }() + } + return out +} + +type freeTrendFeed struct { + label string + url string +} + +func freeTrendFeedQueries(keyword, personaBrief string) []freeTrendFeed { + feeds := []freeTrendFeed{ + {label: "Google Trends 台灣每日趨勢", url: "https://trends.google.com/trends/trendingsearches/daily/rss?geo=TW"}, + {label: "Google Trends 台灣即時趨勢", url: "https://trends.google.com/trends/trendingsearches/realtime/rss?geo=TW&category=all"}, + } + terms := []string{strings.TrimSpace(keyword)} + brief := strings.TrimSpace(personaBrief) + if brief != "" { + if len([]rune(brief)) > 18 { + brief = string([]rune(brief)[:18]) + } + terms = append(terms, brief) + } + for _, term := range terms { + if term == "" { + continue + } + q := url.QueryEscape(term + " 共鳴 OR 痛點 OR 趨勢") + feeds = append(feeds, freeTrendFeed{ + label: "Google News 搜尋:" + term, + url: "https://news.google.com/rss/search?q=" + q + "&hl=zh-TW&gl=TW&ceid=TW:zh-Hant", + }) + } + return feeds +} + +func cleanRSSField(raw string) string { + raw = html.UnescapeString(raw) + raw = rssTagRE.ReplaceAllString(raw, " ") + raw = strings.Join(strings.Fields(raw), " ") + if len([]rune(raw)) > 220 { + return string([]rune(raw)[:220]) + "…" + } + return raw +} diff --git a/backend/internal/library/viral/mission_inspire.go b/backend/internal/library/viral/mission_inspire.go index 32f4718..17d3884 100644 --- a/backend/internal/library/viral/mission_inspire.go +++ b/backend/internal/library/viral/mission_inspire.go @@ -21,6 +21,8 @@ type MissionInspireInput struct { PersonaPillars []string RecentMissionLabels []string RecentSeedQueries []string + AvoidTopics []string + ContentDirection string UserKeyword string TrendSnippets []MissionInspireTrendSnippet WebSearchProvider string @@ -45,27 +47,50 @@ type MissionInspireThreadsInput struct { } type MissionInspireOutput struct { - Label string - SeedQuery string - Brief string - TrendReason string - TrendKeywords []string + Label string + SeedQuery string + Brief string + TrendReason string + TrendKeywords []string + Angles []string + Mission string + TargetAudience string + OpeningType string + BodyType string + Emotion string + CtaType string + RiskLevel string + Avoid []string } func BuildMissionInspireSystemPrompt() string { - return strings.TrimSpace(`你是 Threads 拷貝忍者的「靈感骰子」顧問。根據近期 Threads 熱門貼文、網路搜尋訊號與創作者人設,產出一組**全新**拷貝任務草稿。 + return strings.TrimSpace(`你是 Threads 帳號的內容編輯與 Topic Radar。根據近期 Threads / 社群討論訊號、網路搜尋素材與創作者人設,先產出「可執行的內容計畫」,不要直接寫貼文正文。 規則: -1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;不要編造不存在的時事或熱門貼文 +1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;優先使用社群熱門關鍵字、受眾求助、心得討論、留言常見問題,不要把新聞事件本身當主題 2. 若趨勢訊號為空(未連線網路搜尋),**必須**改依人設、受眾痛點與常見 Threads 討論型態推測「近期可能被搜尋」的話題,並在 trendReason 說明推測理由(不要假裝有外部熱搜來源) 3. label:任務名稱,6~18 字,像企劃案標題,不要標點堆疊 4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點 -5. brief:這次想找什麼,2~4 句,說明要海巡哪類爆款、語氣或格式偏好 -6. trendReason:1~2 句,為何選這個趨勢(可引用訊號來源大意,不要貼 URL) -7. trendKeywords:3~6 個相關熱詞(字串陣列) -8. 避開【近期已做過的任務】相同題材 -9. 繁體中文;只回傳 JSON: -{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[]}`) +5. brief:這次內容計畫摘要,2~4 句,說明話題、受眾痛點、為何適合這個帳號 +6. trendReason:1~2 句,為何選這個趨勢(說明它和受眾討論、搜尋意圖或痛點的關係,不要貼 URL) +7. trendKeywords:3~6 個相關熱詞(字串陣列),必須像 Threads 會搜尋/討論的關鍵字,不要像新聞標題 +8. angles:3~5 個可勾選切角(字串陣列),每個切角都要說明「這篇要怎麼切」,不要直接寫成完整貼文 +9. mission:內容任務,只能是「日常陪伴」「專業知識」「情緒共鳴」「工具清單」「熱門觀點」「互動提問」之一,除非使用者方向要求更精準 +10. targetAudience:這篇真正要對誰說,越具體越好 +11. openingType:開頭策略,例如「問題開場」「場景開場」「反直覺開場」「經驗開場」「清單開場」 +12. bodyType:中段結構,例如「先共鳴再整理」「問題拆解」「步驟清單」「案例對照」「觀點辯證」 +13. emotion:情緒槓桿,例如「焦慮被接住」「想被理解」「怕踩雷」「想準備好」「想被鼓勵」 +14. ctaType:互動/收藏/私訊等 CTA 型態,不要硬業配 +15. riskLevel:low / medium / high;醫療、財務、法律、政策、名人爭議等至少 medium +16. avoid:3~6 個禁忌或不要寫偏的方向(字串陣列) +17. 避開【近期已做過的任務】相同題材 +18. 起號優先:題目要能連續延伸 5~10 篇,不要只是一則熱搜心得 +19. 除非使用者關鍵字、人設或趨勢訊號明確要求,不要產出早餐、早安、日常飲食、純生活流水帳這類泛題 +20. 人設介紹只用來判斷語氣與受眾,不是題材庫。不可因人設寫 Y2K、咖啡、穿搭、音樂,就把靈感主題漂移成咖啡、穿搭或復古日常 +21. 若使用者有指定關鍵字,label、seedQuery、brief 都必須保留該方向,不可漂移成無關日常題 +22. 繁體中文;只回傳 JSON: +23. 避免新聞導向:不要產出「某事件最新進展」「政策新聞懶人包」「名人/品牌新聞」這類題目;除非它已經轉化成受眾正在問的生活問題或經驗討論 +{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[],"angles":[],"mission":"","targetAudience":"","openingType":"","bodyType":"","emotion":"","ctaType":"","riskLevel":"low","avoid":[]}`) } func BuildMissionInspireUserPrompt(in MissionInspireInput) string { @@ -76,9 +101,9 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string { b.WriteString("\n") } if p := strings.TrimSpace(in.PersonaBlock); p != "" { - b.WriteString("【人設摘要】\n") + b.WriteString("【人設摘要(只用來校準語氣與受眾,不是題材庫)】\n") b.WriteString(p) - b.WriteString("\n") + b.WriteString("\n注意:不要把人設裡的名詞、興趣或裝飾符號直接變成話題。\n") } if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" { b.WriteString("【對標帳號】@") @@ -105,8 +130,20 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string { b.WriteString(keyword) b.WriteString("\n") } - if len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 { + if direction := strings.TrimSpace(in.ContentDirection); direction != "" { + b.WriteString("【使用者選擇內容方向】") + b.WriteString(direction) + b.WriteString("\n請讓 label、brief、angles 都符合這個方向;如果方向是 auto,請依熱度與人設自動選。\n") + } + if len(in.AvoidTopics) > 0 || len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 { b.WriteString("【近期已做過的任務(請避開)】\n") + for _, topic := range in.AvoidTopics { + if topic = strings.TrimSpace(topic); topic != "" { + b.WriteString("- ") + b.WriteString(topic) + b.WriteString("\n") + } + } for _, label := range in.RecentMissionLabels { if label = strings.TrimSpace(label); label != "" { b.WriteString("- ") @@ -147,15 +184,15 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string { b.WriteString("\n") } } - b.WriteString("請產出拷貝任務靈感 JSON。") + b.WriteString("請產出 Topic Radar 與 Content Plan JSON。") return b.String() } func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string { queries := []string{ - "Google Trends 台灣 今日 熱門搜尋 關鍵字", - "Threads 台灣 熱門 話題 最近 一週", - "近期 網路 熱搜 關鍵字 台灣", + "Threads 台灣 熱門 關鍵字 討論 最近 一週", + "Threads 台灣 請問 推薦 心得 最近", + "社群 熱門 關鍵字 台灣 討論 Dcard PTT Threads", } context := strings.TrimSpace(personaBrief + " " + styleBenchmark) if context != "" { @@ -163,7 +200,10 @@ func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string { if len([]rune(trimmed)) > 24 { trimmed = string([]rune(trimmed)[:24]) } - queries = append(queries, trimmed+" 熱門 話題 最近") + queries = append(queries, + trimmed+" Threads 熱門 關鍵字 討論", + trimmed+" 請問 心得 推薦 Threads", + ) } return queries } @@ -186,9 +226,11 @@ func InspireThreadsSearchQueries(in MissionInspireThreadsInput) []string { keyword := cleanInspireQuery(in.Keyword) if keyword != "" { add(keyword) - add(keyword + " 心情") - add(keyword + " 經驗") + add(keyword + " 熱門") + add(keyword + " 心得") add(keyword + " 請問") + add(keyword + " 推薦") + add(keyword + " 怎麼辦") } for _, pillar := range in.Pillars { if len(out) >= 6 { @@ -324,10 +366,17 @@ func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) { return MissionInspireOutput{}, err } out := MissionInspireOutput{ - Label: firstJSONString(root, "label", "title", "mission_label"), - SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"), - Brief: firstJSONString(root, "brief", "description", "goal"), - TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"), + Label: firstJSONString(root, "label", "title", "mission_label"), + SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"), + Brief: firstJSONString(root, "brief", "description", "goal"), + TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"), + Mission: firstJSONString(root, "mission", "contentMission", "content_mission"), + TargetAudience: firstJSONString(root, "targetAudience", "target_audience", "audience"), + OpeningType: firstJSONString(root, "openingType", "opening_type", "hookType", "hook_type"), + BodyType: firstJSONString(root, "bodyType", "body_type", "structure", "bodyStructure"), + Emotion: firstJSONString(root, "emotion", "emotionalLever", "emotional_lever"), + CtaType: firstJSONString(root, "ctaType", "cta_type", "cta"), + RiskLevel: firstJSONString(root, "riskLevel", "risk_level", "risk"), } if out.Label == "" || out.SeedQuery == "" || out.Brief == "" { return MissionInspireOutput{}, fmt.Errorf("missing label, seedQuery, or brief") @@ -340,5 +389,21 @@ func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) { out.TrendKeywords = parseFlexibleStringList(rawKW) } } + if rawAngles, ok := root["angles"]; ok { + out.Angles = parseFlexibleStringList(rawAngles) + } + if len(out.Angles) == 0 { + if rawAngles, ok := root["contentAngles"]; ok { + out.Angles = parseFlexibleStringList(rawAngles) + } + } + if rawAvoid, ok := root["avoid"]; ok { + out.Avoid = parseFlexibleStringList(rawAvoid) + } + if len(out.Avoid) == 0 { + if rawAvoid, ok := root["avoidTopics"]; ok { + out.Avoid = parseFlexibleStringList(rawAvoid) + } + } return out, nil } diff --git a/backend/internal/library/viral/mission_inspire_collect.go b/backend/internal/library/viral/mission_inspire_collect.go index c22fdc1..febd496 100644 --- a/backend/internal/library/viral/mission_inspire_collect.go +++ b/backend/internal/library/viral/mission_inspire_collect.go @@ -49,6 +49,9 @@ func CollectMissionInspireTrends( if title == "" && snippet == "" { continue } + if looksLikeNewsResult(title, snippet, item.URL) { + continue + } key := strings.ToLower(title + "|" + snippet) if _, ok := seen[key]; ok { continue @@ -64,3 +67,24 @@ func CollectMissionInspireTrends( } return out } + +func looksLikeNewsResult(title, snippet, url string) bool { + text := strings.ToLower(title + " " + snippet + " " + url) + newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"} + socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"} + newsScore := 0 + for _, signal := range newsSignals { + if strings.Contains(text, strings.ToLower(signal)) { + newsScore++ + } + } + if newsScore == 0 { + return false + } + for _, signal := range socialSignals { + if strings.Contains(text, strings.ToLower(signal)) { + return false + } + } + return true +} diff --git a/backend/internal/library/viral/replicate.go b/backend/internal/library/viral/replicate.go index 6be7051..ef01504 100644 --- a/backend/internal/library/viral/replicate.go +++ b/backend/internal/library/viral/replicate.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" + "haixun-backend/internal/library/copyvoice" "haixun-backend/internal/library/threadspost" ) @@ -19,6 +20,7 @@ type ReplicateInput struct { OriginalText string AuthorName string StructureAnalysis string + NarrativeSeed string } type ReplicateResult struct { @@ -30,13 +32,15 @@ type ReplicateResult struct { } func BuildSystemPrompt() string { - return strings.TrimSpace(`你是 Threads 爆款複製策略師。根據參考爆文,為使用者撰寫「同結構、同節奏、但完全原創」的貼文。 + return strings.TrimSpace(`你是 Threads / Instagram 創作者。讀完參考爆文後,用指定人設的語言指紋寫一篇全新貼文。 -規則: -- 複製的是爆款公式(hook 手法、情緒節奏),不是抄襲原文 -- 文筆必須像創作者本人,套用其 8D 風格策略 +` + copyvoice.SystemRules() + ` + +輸出: - text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤) -- 爆款互動最佳 80~220 字:前 1~2 行強 hook,口語精簡;超過 300 字互動通常下降 +- 參考原文只學情緒力度、資訊密度與節奏;最後必須換成人設會講出口的話 +- 篇幅、行數、標點與換行優先遵守人設語言指紋;沒有指紋時才貼近參考原文 +- text 排版要像真人手機發文:可使用自然標點、空行、列點與少量 emoji;不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣 - 只回傳一個 JSON 物件,欄位:angle, hook, text, rationale, structureNotes`) } @@ -51,29 +55,39 @@ func BuildUserPrompt(input ReplicateInput) string { b.WriteString("\n") } if persona := strings.TrimSpace(input.Persona); persona != "" { - b.WriteString("\n人設與語氣:\n") + b.WriteString("\n人設定位、語言指紋與禁忌(最高優先):\n") b.WriteString(persona) b.WriteString("\n") + b.WriteString(copyvoice.PersonaCalibrationNote()) + b.WriteString("\n") } if style := strings.TrimSpace(input.StyleProfile); style != "" { b.WriteString("\n8D 風格策略:\n") b.WriteString(style) b.WriteString("\n") } + if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" { + b.WriteString("\n敘事分析(只學故事感,不要套句型或段落公式):\n") + b.WriteString(analysis) + b.WriteString("\n") + } author := strings.TrimSpace(input.AuthorName) if author == "" { author = "匿名" } - if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" { - b.WriteString("\n爆款結構分析(仿寫時套用):\n") - b.WriteString(analysis) + original := strings.TrimSpace(input.OriginalText) + if note := threadspost.MimicLengthGuidance(original); note != "" { + b.WriteString("\n") + b.WriteString(note) b.WriteString("\n") } b.WriteString("\n原文參考(@") b.WriteString(author) - b.WriteString(",只學結構不抄內容):\n") - b.WriteString(strings.TrimSpace(input.OriginalText)) - b.WriteString("\n\n請產出一篇可發布的複製版貼文 JSON。") + b.WriteString(",只感受情緒、敘事與篇幅,禁止抄句型):\n") + b.WriteString(original) + b.WriteString("\n\n本次敘事切入:") + b.WriteString(copyvoice.NarrativeLens(input.NarrativeSeed)) + b.WriteString("\n\n請產出一篇可發布的全新貼文 JSON。hook 欄位只放內部分析,不要把模板句型寫進 text。") return b.String() } @@ -86,7 +100,7 @@ func ParseReplicateOutput(raw string) (ReplicateResult, error) { if err := json.Unmarshal(payload, &out); err != nil { return ReplicateResult{}, fmt.Errorf("parse viral replica json: %w", err) } - out.Text = trimText(out.Text) + out.Text = threadspost.FormatDraftText(out.Text) if out.Text == "" { return ReplicateResult{}, fmt.Errorf("replica text missing") } @@ -97,15 +111,6 @@ func ParseReplicateOutput(raw string) (ReplicateResult, error) { return out, nil } -func trimText(text string) string { - text = strings.TrimSpace(text) - runes := []rune(text) - if len(runes) > threadspost.MaxPublishRunes { - return string(runes[:threadspost.MaxPublishRunes]) - } - return text -} - func extractJSONObject(raw string) ([]byte, error) { raw = strings.TrimSpace(raw) if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 { diff --git a/backend/internal/logic/copy_mission/capabilities.go b/backend/internal/logic/copy_mission/capabilities.go deleted file mode 100644 index 98e8c57..0000000 --- a/backend/internal/logic/copy_mission/capabilities.go +++ /dev/null @@ -1,16 +0,0 @@ -package copy_mission - -import ( - "context" - - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/svc" -) - -func requireMemberAiReady(ctx context.Context, svcCtx *svc.ServiceContext, tenantID, uid string) error { - if _, err := svcCtx.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, uid); err != nil { - return app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 AI API key") - } - return nil -} diff --git a/backend/internal/logic/copy_mission/create_copy_mission_logic.go b/backend/internal/logic/copy_mission/create_copy_mission_logic.go index cc652ca..86aea3b 100644 --- a/backend/internal/logic/copy_mission/create_copy_mission_logic.go +++ b/backend/internal/logic/copy_mission/create_copy_mission_logic.go @@ -1,10 +1,11 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -25,33 +26,8 @@ func NewCreateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) } } -func (l *CreateCopyMissionLogic) CreateCopyMission(req *types.CreateCopyMissionHandlerReq) (*types.CopyMissionData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) - if err != nil { - return nil, err - } - initialMap, initialTags := seedMissionResearchFromPersona(*persona) - createReq := missiondomain.CreateRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - Label: req.Label, - SeedQuery: req.SeedQuery, - Brief: req.Brief, - } - if initialMap != nil { - createReq.InitialResearchMap = initialMap - createReq.InitialSelectedTags = initialTags - } - item, err := l.svcCtx.CopyMission.Create(l.ctx, createReq) - if err != nil { - return nil, err - } - data := toCopyMissionData(*item) - return &data, nil +func (l *CreateCopyMissionLogic) CreateCopyMission(req *types.CreateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/delete_copy_mission_logic.go b/backend/internal/logic/copy_mission/delete_copy_mission_logic.go index 6c64daf..65d8414 100644 --- a/backend/internal/logic/copy_mission/delete_copy_mission_logic.go +++ b/backend/internal/logic/copy_mission/delete_copy_mission_logic.go @@ -1,8 +1,10 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -25,9 +27,7 @@ func NewDeleteCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) } func (l *DeleteCopyMissionLogic) DeleteCopyMission(req *types.CopyMissionPath) error { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return err - } - return l.svcCtx.CopyMission.Delete(l.ctx, tenantID, uid, strings.TrimSpace(req.PersonaID), strings.TrimSpace(req.ID)) + // todo: add your logic here and delete this line + + return nil } diff --git a/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go b/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go index 9e2d55d..960b831 100644 --- a/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go +++ b/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go @@ -1,52 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "fmt" - "strings" - copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type DeleteCopyMissionMatrixDraftsLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewDeleteCopyMissionMatrixDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCopyMissionMatrixDraftsLogic { - return &DeleteCopyMissionMatrixDraftsLogic{ctx: ctx, svcCtx: svcCtx} + return &DeleteCopyMissionMatrixDraftsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *DeleteCopyMissionMatrixDraftsLogic) DeleteCopyMissionMatrixDrafts( - req *types.DeleteCopyMissionMatrixDraftsHandlerReq, -) (*types.DeleteCopyMissionMatrixDraftsData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - deleted, err := l.svcCtx.CopyDraft.DeleteMissionMatrixDrafts(l.ctx, copydraftdomain.DeleteMissionMatrixDraftsRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - MissionID: missionID, - DraftIDs: req.DraftIDs, - }) - if err != nil { - return nil, err - } - message := fmt.Sprintf("已刪除 %d 篇矩陣草稿", deleted) - if len(req.DraftIDs) == 0 { - message = fmt.Sprintf("已刪除全部 %d 篇矩陣草稿", deleted) - } - return &types.DeleteCopyMissionMatrixDraftsData{ - Deleted: deleted, - Message: message, - }, nil +func (l *DeleteCopyMissionMatrixDraftsLogic) DeleteCopyMissionMatrixDrafts(req *types.DeleteCopyMissionMatrixDraftsHandlerReq) (resp *types.DeleteCopyMissionMatrixDraftsData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go b/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go index b406f74..e621f52 100644 --- a/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go +++ b/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go @@ -1,14 +1,11 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - libkg "haixun-backend/internal/library/knowledge" - "haixun-backend/internal/library/placement" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -29,68 +26,8 @@ func NewExpandCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceCont } } -func (l *ExpandCopyMissionGraphLogic) ExpandCopyMissionGraph(req *types.ExpandCopyMissionGraphHandlerReq) (*types.ExpandKnowledgeGraphData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - seed := strings.TrimSpace(req.SeedQuery) - if seed == "" { - return nil, app.For(code.Persona).InputMissingRequired("seed_query is required") - } - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } - if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { - return nil, app.For(code.Persona).InputMissingRequired("請先產生研究地圖再擴展延伸知識") - } - if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil { - return nil, err - } +func (l *ExpandCopyMissionGraphLogic) ExpandCopyMissionGraph(req *types.ExpandCopyMissionGraphHandlerReq) (resp *types.ExpandKnowledgeGraphData, err error) { + // todo: add your logic here and delete this line - research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) - if err != nil { - return nil, err - } - expandStrategy := placement.EffectiveExpandStrategy(research) - if req.Supplemental && placement.WebSearchAvailable(research) { - expandStrategy = libkg.ExpandStrategyBrave - } - memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) - if err != nil { - return nil, err - } - - payload := map[string]any{ - "tenant_id": tenantID, - "owner_uid": uid, - "persona_id": personaID, - "copy_mission_id": missionID, - "seed_query": seed, - "supplemental": req.Supplemental, - "expand_strategy": expandStrategy.String(), - } - for key, value := range memberCtx.PayloadFields() { - payload[key] = value - } - - run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ - TemplateType: "expand-copy-mission-graph", - Scope: "copy_mission", - ScopeID: missionID, - TenantID: tenantID, - OwnerUID: uid, - Payload: payload, - }) - if err != nil { - return nil, err - } - return &types.ExpandKnowledgeGraphData{ - JobID: run.ID.Hex(), - Status: string(run.Status), - Message: "延伸知識擴展中,完成後可勾選節點用於產文", - }, nil + return } diff --git a/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go b/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go index 3d2be76..f949569 100644 --- a/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go +++ b/backend/internal/logic/copy_mission/generate_copy_mission_matrix_logic.go @@ -1,226 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "fmt" - "strings" - libcopy "haixun-backend/internal/library/copymission" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - libkg "haixun-backend/internal/library/knowledge" - libmatrix "haixun-backend/internal/library/matrix" - libprompt "haixun-backend/internal/library/prompt" - "haixun-backend/internal/library/style8d" - libweb "haixun-backend/internal/library/webpage" - domai "haixun-backend/internal/model/ai/domain/usecase" - aiusecase "haixun-backend/internal/model/ai/usecase" - copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" - copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type GenerateCopyMissionMatrixLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewGenerateCopyMissionMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateCopyMissionMatrixLogic { - return &GenerateCopyMissionMatrixLogic{ctx: ctx, svcCtx: svcCtx} + return &GenerateCopyMissionMatrixLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix( - req *types.GenerateCopyMissionMatrixHandlerReq, -) (*types.GenerateCopyMissionMatrixData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } - if mission.Status != string(missionentity.StatusMapped) && - mission.Status != string(missionentity.StatusScanned) && - mission.Status != string(missionentity.StatusDrafted) { - return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣") - } - if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { - return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖") - } +func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(req *types.GenerateCopyMissionMatrixHandlerReq) (resp *types.GenerateCopyMissionMatrixData, err error) { + // todo: add your logic here and delete this line - persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) - if err != nil { - return nil, err - } - if err := ensureNoActiveMatrixJob(l.ctx, l.svcCtx, missionID); err != nil { - return nil, err - } - if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { - return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") - } - personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) - - count := req.Count - if count <= 0 { - count = 5 - } - if count > 12 { - count = 12 - } - - posts, err := l.svcCtx.ScanPost.ListForPersona(l.ctx, scanpostdomain.PersonaListRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - CopyMissionID: missionID, - Limit: 12, - }) - if err != nil { - return nil, err - } - samples := matrixSamplesFromScanPosts(posts) - graphNodes, graphSources := loadMatrixGraph(l.ctx, l.svcCtx, tenantID, uid, missionID) - knowledgeItems := libcopy.MatrixKnowledgeItems( - mission.ResearchMap.SelectedKnowledgeItems, - mission.ResearchMap.KnowledgeItems, - graphNodes, - mission.SelectedTags, - ) - researchSources := libcopy.ResearchSourcesFromSummaries(mission.ResearchMap.ResearchItems) - refURLs := libcopy.MergeReferenceURLs( - libcopy.ReferenceURLsFromSelection(graphNodes, graphSources), - researchSources, - ) - researchItemsBlock := libcopy.FormatResearchItemsForMatrix(researchSources) - referenceMarkdown := "" - if len(refURLs) > 0 { - digests := libweb.FetchDigests(l.ctx, refURLs, libweb.FetchOptions{}) - referenceMarkdown = libmatrix.BuildReferenceMarkdown(digests) - } - researchBlock := libmatrix.FormatCopyResearchMapBlockWithReferences( - mission.ResearchMap.AudienceSummary, - mission.ResearchMap.ContentGoal, - mission.ResearchMap.Questions, - mission.ResearchMap.Pillars, - mission.ResearchMap.Exclusions, - knowledgeItems, - researchItemsBlock, - referenceMarkdown, - ) - userPrompt, err := libmatrix.BuildCopyUserPrompt(libmatrix.CopyGenerateInput{ - Count: count, - TopicLabel: mission.Label, - TopicBrief: mission.Brief, - ResearchMap: researchBlock, - SelectedTags: mission.SelectedTags, - ViralSamples: samples, - PersonaBlock: personaBlock, - }) - if err != nil { - return nil, app.For(code.AI).SysInternal("matrix user prompt load failed") - } - systemPrompt, err := libprompt.MatrixCopySystem() - if err != nil { - return nil, app.For(code.AI).SysInternal("matrix system prompt load failed") - } - 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 - } - parsed, err := libmatrix.GenerateCopyOutput(l.ctx, l.svcCtx.AI, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - }, - }, count) - if err != nil { - return nil, app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error()) - } - - createReqs := make([]copydraftdomain.CreateRequest, 0, len(parsed.Rows)) - for _, row := range parsed.Rows { - createReqs = append(createReqs, copydraftdomain.CreateRequest{ - CopyMissionID: missionID, - DraftType: copydraftentity.DraftTypeMatrix, - SortOrder: row.SortOrder, - Text: row.Text, - Angle: row.Angle, - Hook: row.Hook, - Rationale: row.Rationale, - ReferenceNotes: row.ReferenceNotes, - Sources: row.SourcePermalinks, - }) - } - saved, err := l.svcCtx.CopyDraft.ReplaceMissionMatrix(l.ctx, tenantID, uid, personaID, missionID, createReqs) - if err != nil { - return nil, err - } - - drafted := missionentity.StatusDrafted - _, _ = l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - MissionID: missionID, - Patch: missiondomain.MissionPatch{ - Status: &drafted, - }, - }) - - drafts := make([]types.CopyDraftData, 0, len(saved)) - for _, item := range saved { - drafts = append(drafts, toCopyDraftData(item)) - } - return &types.GenerateCopyMissionMatrixData{ - Drafts: drafts, - Message: fmt.Sprintf("已產出 %d 篇矩陣草稿", len(drafts)), - }, nil -} - -func loadMatrixGraph(ctx context.Context, svcCtx *svc.ServiceContext, tenantID, uid, missionID string) ([]libkg.Node, []libkg.BraveSource) { - if svcCtx == nil || svcCtx.KnowledgeGraph == nil { - return nil, nil - } - graph, err := svcCtx.KnowledgeGraph.GetByCopyMission(ctx, tenantID, uid, missionID) - if err != nil || graph == nil { - return nil, nil - } - return graph.Nodes, graph.BraveSources -} - -func matrixSamplesFromScanPosts(posts []scanpostdomain.ScanPostSummary) string { - samples := make([]libmatrix.ViralPostSample, 0, len(posts)) - for _, post := range posts { - replies := make([]libmatrix.ViralReplySample, 0, len(post.Replies)) - for _, reply := range post.Replies { - replies = append(replies, libmatrix.ViralReplySample{ - Author: reply.Author, - Text: reply.Text, - }) - } - samples = append(samples, libmatrix.ViralPostSample{ - Author: post.Author, - LikeCount: post.LikeCount, - SearchTag: post.SearchTag, - Text: post.Text, - Replies: replies, - }) - } - return libmatrix.FormatViralSamples(samples) + return } diff --git a/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go b/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go index c90a836..f833033 100644 --- a/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go +++ b/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go @@ -1,8 +1,10 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -24,20 +26,8 @@ func NewGetCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext } } -func (l *GetCopyMissionGraphLogic) GetCopyMissionGraph(req *types.CopyMissionPath) (*types.KnowledgeGraphData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - graph, err := l.svcCtx.KnowledgeGraph.GetByCopyMission(l.ctx, tenantID, uid, missionID) - if err != nil { - return nil, err - } - data := toKnowledgeGraphData(graph) - return &data, nil +func (l *GetCopyMissionGraphLogic) GetCopyMissionGraph(req *types.CopyMissionPath) (resp *types.KnowledgeGraphData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/get_copy_mission_logic.go b/backend/internal/logic/copy_mission/get_copy_mission_logic.go index 2bb8467..c0963a9 100644 --- a/backend/internal/logic/copy_mission/get_copy_mission_logic.go +++ b/backend/internal/logic/copy_mission/get_copy_mission_logic.go @@ -1,8 +1,10 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -24,15 +26,8 @@ func NewGetCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Ge } } -func (l *GetCopyMissionLogic) GetCopyMission(req *types.CopyMissionPath) (*types.CopyMissionData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - item, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, strings.TrimSpace(req.PersonaID), strings.TrimSpace(req.ID)) - if err != nil { - return nil, err - } - data := toCopyMissionData(*item) - return &data, nil +func (l *GetCopyMissionLogic) GetCopyMission(req *types.CopyMissionPath) (resp *types.CopyMissionData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go b/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go index 4b23184..42d3c9e 100644 --- a/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go +++ b/backend/internal/logic/copy_mission/get_copy_mission_scan_schedule_logic.go @@ -1,40 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type GetCopyMissionScanScheduleLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewGetCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionScanScheduleLogic { - return &GetCopyMissionScanScheduleLogic{ctx: ctx, svcCtx: svcCtx} + return &GetCopyMissionScanScheduleLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *GetCopyMissionScanScheduleLogic) GetCopyMissionScanSchedule( - req *types.CopyMissionPath, -) (*types.CopyMissionScanScheduleData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { - return nil, err - } - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - schedule, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, missionID) - if err != nil { - return nil, err - } - return toCopyMissionScanScheduleData(schedule, personaID, missionID), nil +func (l *GetCopyMissionScanScheduleLogic) GetCopyMissionScanSchedule(req *types.CopyMissionPath) (resp *types.CopyMissionScanScheduleData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go b/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go index c3d5fef..98fe6ea 100644 --- a/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go +++ b/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go @@ -1,8 +1,12 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" "strings" + "time" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" @@ -12,91 +16,41 @@ import ( "haixun-backend/internal/library/websearch" domai "haixun-backend/internal/model/ai/domain/usecase" aiusecase "haixun-backend/internal/model/ai/usecase" + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type InspireCopyMissionLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspireCopyMissionLogic { - return &InspireCopyMissionLogic{ctx: ctx, svcCtx: svcCtx} + return &InspireCopyMissionLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (*types.CopyMissionInspirationData, error) { +func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (resp *types.CopyMissionInspirationData, err error) { tenantID, uid, err := actorFrom(l.ctx) if err != nil { return nil, err } personaID := strings.TrimSpace(req.PersonaID) - userKeyword := strings.TrimSpace(req.Keyword) + if personaID == "" { + return nil, app.For(code.Persona).InputMissingRequired("persona_id is required") + } persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) if err != nil { return nil, err } - - missionList, err := l.svcCtx.CopyMission.List(l.ctx, tenantID, uid, personaID) - if err != nil { - return nil, err - } - recentLabels := make([]string, 0, 8) - recentSeeds := make([]string, 0, 8) - for _, mission := range missionList.List { - if mission.Status == "archived" { - continue - } - if label := strings.TrimSpace(mission.Label); label != "" { - recentLabels = append(recentLabels, label) - } - if seed := strings.TrimSpace(mission.SeedQuery); seed != "" { - recentSeeds = append(recentSeeds, seed) - } - if len(recentLabels) >= 8 && len(recentSeeds) >= 8 { - break - } - } - - trendSnippets := []libviral.MissionInspireTrendSnippet{} - webSearchProvider := "" - trendSourceLabel := "" - llmOnly := true - - research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) - if researchErr == nil { - memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) - if memberErr == nil { - trendSnippets = libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{ - Keyword: userKeyword, - PersonaBrief: persona.Brief, - StyleBenchmark: persona.StyleBenchmark, - Pillars: append([]string(nil), persona.CopyResearchMap.Pillars...), - Questions: append([]string(nil), persona.CopyResearchMap.Questions...), - Member: memberCtx, - Crawler: l.makeCrawlerSearchFn(tenantID, uid), - }) - if len(trendSnippets) > 0 { - trendSourceLabel = memberCtx.DiscoverPathLabel() - llmOnly = false - } - if len(trendSnippets) == 0 && placement.WebSearchAvailable(research) { - webClient := websearch.New(memberCtx.WebSearchConfig()) - trendSnippets = libviral.CollectMissionInspireTrends( - l.ctx, - webClient, - memberCtx, - persona.Brief+" "+userKeyword, - persona.StyleBenchmark, - ) - webSearchProvider = memberCtx.WebSearchProviderLabel() - trendSourceLabel = webSearchProvider - llmOnly = len(trendSnippets) == 0 - } - } - } - webSearchUsed := len(trendSnippets) > 0 && webSearchProvider != "" - credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) if err != nil { return nil, err @@ -106,47 +60,280 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspi return nil, err } + keyword := strings.TrimSpace(req.Keyword) + trends, sources, searchUsed := []libviral.MissionInspireTrendSnippet(nil), []types.CopyMissionInspirationSourceData(nil), false + if req.UseWebSearch { + trends, sources, searchUsed = l.collectTrendSignals(tenantID, uid, persona, keyword) + } + llmOnly := len(trends) == 0 + if keyword != "" && llmOnly { + trends = append(trends, libviral.MissionInspireTrendSnippet{ + Query: keyword, + Title: "使用者指定方向", + Snippet: keyword, + }) + } + personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) + temp := 0.72 result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: libviral.BuildMissionInspireSystemPrompt(), - Messages: []domai.Message{ - { - Role: "user", - Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{ - PersonaDisplayName: persona.DisplayName, - PersonaBrief: persona.Brief, - PersonaBlock: personaBlock, - StyleBenchmark: persona.StyleBenchmark, - PersonaAudience: persona.CopyResearchMap.AudienceSummary, - PersonaContentGoal: persona.CopyResearchMap.ContentGoal, - PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...), - PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...), - RecentMissionLabels: recentLabels, - RecentSeedQueries: recentSeeds, - UserKeyword: userKeyword, - TrendSnippets: trendSnippets, - WebSearchProvider: trendSourceLabel, - LLMOnly: llmOnly, - }), - }, - }, + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{APIKey: credential.APIKey}, + System: libviral.BuildMissionInspireSystemPrompt(), + Temperature: &temp, + Messages: []domai.Message{{Role: "user", Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{ + PersonaDisplayName: persona.DisplayName, + PersonaBrief: persona.Brief, + PersonaBlock: personaBlock, + StyleBenchmark: persona.StyleBenchmark, + AvoidTopics: cleanAvoidTopics(req.AvoidTopics), + ContentDirection: req.ContentDirection, + UserKeyword: keyword, + TrendSnippets: trends, + WebSearchProvider: sourceLabel(searchUsed, sources), + LLMOnly: llmOnly, + })}}, }) if err != nil { - return nil, err + return nil, app.For(code.AI).SvcThirdParty("起號靈感生成失敗:" + err.Error()) } - parsed, err := libviral.ParseMissionInspireOutput(result.Text) if err != nil { - return nil, app.For(code.AI).SvcThirdParty("靈感骰子 LLM 回傳無法解析:" + err.Error()) + return nil, app.For(code.AI).SvcThirdParty("起號靈感回傳無法解析:" + err.Error()) } - sources := make([]types.CopyMissionInspirationSourceData, 0, len(trendSnippets)) - for _, item := range trendSnippets { + topicCandidate, plan := l.persistInspiration(tenantID, uid, personaID, parsed, searchUsed) + return &types.CopyMissionInspirationData{ + TopicCandidateID: topicCandidate, + ContentPlanID: plan, + Label: parsed.Label, + SeedQuery: parsed.SeedQuery, + Brief: parsed.Brief, + TrendReason: parsed.TrendReason, + TrendKeywords: parsed.TrendKeywords, + Angles: parsed.Angles, + Mission: parsed.Mission, + TargetAudience: parsed.TargetAudience, + OpeningType: parsed.OpeningType, + BodyType: parsed.BodyType, + Emotion: parsed.Emotion, + CtaType: parsed.CtaType, + RiskLevel: parsed.RiskLevel, + Avoid: parsed.Avoid, + Sources: sources, + WebSearchUsed: searchUsed, + Message: "已找到一個可延伸的話題與內容計畫", + }, nil +} + +func (l *InspireCopyMissionLogic) persistInspiration(tenantID, uid, personaID string, parsed libviral.MissionInspireOutput, searchUsed bool) (string, string) { + if l.svcCtx.ContentOps == nil { + return "", "" + } + riskScore := riskScoreFromLevel(parsed.RiskLevel) + category := parsed.Mission + if category == "" { + category = "候選話題" + } + source := "llm" + if searchUsed { + source = "topic_radar" + } + topic, err := l.svcCtx.ContentOps.CreateTopicCandidate(l.ctx, contentops.TopicCandidateInput{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: personaID, + Name: parsed.Label, + Source: source, + Category: category, + SeedQuery: parsed.SeedQuery, + TrendReason: parsed.TrendReason, + TrendKeywords: parsed.TrendKeywords, + HeatScore: scoreBySearch(searchUsed), + FitScore: 82, + InteractionScore: 70, + ExtendScore: 78, + RiskScore: riskScore, + RecommendedMissions: []string{parsed.Mission}, + }) + if err != nil { + l.Errorf("persist topic candidate: %v", err) + return "", "" + } + angle := "" + if len(parsed.Angles) > 0 { + angle = parsed.Angles[0] + } + plan, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: personaID, + TopicCandidateID: topic.ID, + Topic: parsed.Label, + Mission: fallbackString(parsed.Mission, "互動文"), + TargetAudience: parsed.TargetAudience, + Angle: angle, + OpeningType: parsed.OpeningType, + BodyType: parsed.BodyType, + Emotion: parsed.Emotion, + CtaType: parsed.CtaType, + RiskLevel: parsed.RiskLevel, + RequiresHumanReview: parsed.RiskLevel != "low", + Avoid: parsed.Avoid, + }) + if err != nil { + l.Errorf("persist content plan: %v", err) + return topic.ID, "" + } + return topic.ID, plan.ID +} + +func scoreBySearch(searchUsed bool) int { + if searchUsed { + return 76 + } + return 55 +} + +func riskScoreFromLevel(level string) int { + switch strings.ToLower(strings.TrimSpace(level)) { + case "high": + return 75 + case "low": + return 20 + default: + return 45 + } +} + +func fallbackString(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + return fallback +} + +func (l *InspireCopyMissionLogic) collectTrendSignals(tenantID, uid string, persona *personadomain.PersonaSummary, keyword string) ([]libviral.MissionInspireTrendSnippet, []types.CopyMissionInspirationSourceData, bool) { + if persona == nil { + return nil, nil, false + } + trends := make([]libviral.MissionInspireTrendSnippet, 0, 12) + seen := map[string]struct{}{} + addTrend := func(item libviral.MissionInspireTrendSnippet) { + item.Query = strings.TrimSpace(item.Query) + item.Title = strings.TrimSpace(item.Title) + item.Snippet = strings.TrimSpace(item.Snippet) + item.URL = strings.TrimSpace(item.URL) + if item.Title == "" && item.Snippet == "" { + return + } + if isNewsLikeInspiration(item.Title, item.Snippet, item.URL) { + return + } + key := strings.ToLower(item.Query + "|" + item.Title + "|" + item.Snippet) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + trends = append(trends, item) + } + + for _, item := range libviral.CollectFreeTopicTrends(l.ctx, keyword, persona.Brief) { + addTrend(item) + } + + research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) + if err != nil { + sources := inspirationSourcesFromTrends(trends) + return trends, sources, len(sources) > 0 + } + memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) + if err != nil { + sources := inspirationSourcesFromTrends(trends) + return trends, sources, len(sources) > 0 + } + + if placement.WebSearchAvailable(research) { + client := websearch.New(memberCtx.WebSearchConfig()) + for _, item := range libviral.CollectMissionInspireTrends(l.ctx, client, memberCtx, strings.TrimSpace(persona.Brief+" "+keyword), persona.StyleBenchmark) { + addTrend(item) + } + if keyword != "" && client.Enabled() { + for _, query := range []string{ + "Threads 台灣 " + keyword + " 熱門 關鍵字 討論", + keyword + " 請問 心得 推薦 Threads", + keyword + " 痛點 經驗 怎麼辦 社群", + keyword + " Dcard PTT Threads 熱門 討論", + } { + res, searchErr := client.Search(l.ctx, websearch.SearchOptions{ + Query: query, + Limit: 4, + Mode: websearch.ModeKnowledgeExpand, + Country: memberCtx.BraveCountry, + SearchLang: memberCtx.BraveSearchLang, + UserLocation: memberCtx.ExaUserLocation, + StartPublishedDate: placement.FormatPublishedAfterISO(14, time.Now().UTC()), + }) + if searchErr != nil || res.Status != "success" { + continue + } + for _, result := range res.Results { + addTrend(libviral.MissionInspireTrendSnippet{ + Query: query, + Title: result.Title, + Snippet: result.Snippet, + URL: result.URL, + }) + } + } + } + } + + if keyword != "" && len(trends) < 8 { + for _, item := range libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{ + Keyword: keyword, + PersonaBrief: persona.Brief, + StyleBenchmark: persona.StyleBenchmark, + Pillars: persona.CopyResearchMap.Pillars, + Questions: persona.CopyResearchMap.Questions, + Member: memberCtx, + }) { + addTrend(item) + } + } + + if len(trends) > 10 { + trends = trends[:10] + } + sources := inspirationSourcesFromTrends(trends) + return trends, sources, len(sources) > 0 +} + +func isNewsLikeInspiration(title, snippet, url string) bool { + text := strings.ToLower(title + " " + snippet + " " + url) + newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"} + socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"} + newsScore := 0 + for _, signal := range newsSignals { + if strings.Contains(text, strings.ToLower(signal)) { + newsScore++ + } + } + if newsScore == 0 { + return false + } + for _, signal := range socialSignals { + if strings.Contains(text, strings.ToLower(signal)) { + return false + } + } + return true +} + +func inspirationSourcesFromTrends(trends []libviral.MissionInspireTrendSnippet) []types.CopyMissionInspirationSourceData { + sources := make([]types.CopyMissionInspirationSourceData, 0, len(trends)) + for _, item := range trends { sources = append(sources, types.CopyMissionInspirationSourceData{ Query: item.Query, Title: item.Title, @@ -154,37 +341,33 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspi URL: item.URL, }) } - - message := "已依近期 Threads 熱門素材產出任務靈感,可微調後建立任務" - if webSearchUsed { - message = "已依近期網路趨勢產出任務靈感,可微調後建立任務" - } - if len(trendSnippets) == 0 { - message = "未取得外部趨勢素材,已改由 LLM 依人設與關鍵字推測靈感;同步 Chrome Session、完成 Threads API 或補上 Brave/Exa key 可更貼近熱搜" - } - - return &types.CopyMissionInspirationData{ - Label: parsed.Label, - SeedQuery: parsed.SeedQuery, - Brief: parsed.Brief, - TrendReason: parsed.TrendReason, - TrendKeywords: parsed.TrendKeywords, - Sources: sources, - WebSearchUsed: webSearchUsed, - Message: message, - }, nil + return sources } -func (l *InspireCopyMissionLogic) makeCrawlerSearchFn(tenantID, ownerUID string) placement.CrawlerSearchFn { - return func(ctx context.Context, member placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) { - accountID := strings.TrimSpace(member.ActiveAccountID) - if accountID == "" { - return nil, app.For(code.Setting).InputMissingRequired("請先選定經營帳號並同步 Chrome Session") - } - session, err := l.svcCtx.ThreadsAccount.GetBrowserSession(ctx, tenantID, ownerUID, accountID) - if err != nil { - return nil, err - } - return placement.RunExecCrawlerSearch(ctx, session.StorageState, keyword, limit) +func sourceLabel(used bool, sources []types.CopyMissionInspirationSourceData) string { + if !used || len(sources) == 0 { + return "" } + return "Free Trend / Web / Threads Search" +} + +func cleanAvoidTopics(items []string) []string { + out := make([]string, 0, len(items)) + seen := map[string]struct{}{} + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + key := strings.ToLower(item) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + if len(out) >= 8 { + break + } + } + return out } diff --git a/backend/internal/logic/copy_mission/knowledge_graph_mapper.go b/backend/internal/logic/copy_mission/knowledge_graph_mapper.go deleted file mode 100644 index 0a9d13a..0000000 --- a/backend/internal/logic/copy_mission/knowledge_graph_mapper.go +++ /dev/null @@ -1,67 +0,0 @@ -package copy_mission - -import ( - kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" - "haixun-backend/internal/types" -) - -func toKnowledgeGraphData(graph *kgusecase.GraphSummary) types.KnowledgeGraphData { - if graph == nil { - return types.KnowledgeGraphData{} - } - nodes := make([]types.KnowledgeGraphNodeData, 0, len(graph.Nodes)) - for _, node := range graph.Nodes { - evidence := make([]types.KnowledgeGraphEvidenceData, 0, len(node.Evidence)) - for _, item := range node.Evidence { - evidence = append(evidence, types.KnowledgeGraphEvidenceData{ - URL: item.URL, - Snippet: item.Snippet, - Query: item.Query, - }) - } - nodes = append(nodes, types.KnowledgeGraphNodeData{ - ID: node.ID, - Label: node.Label, - NodeKind: node.NodeKind, - Type: node.Type, - Layer: node.Layer, - Relation: node.Relation, - PlacementValue: node.PlacementValue, - ProductFitScore: node.ProductFitScore, - SelectedForScan: node.SelectedForScan, - RelevanceTags: append([]string{}, node.DerivedTags.Relevance...), - RecencyTags: append([]string{}, node.DerivedTags.Recency...), - Evidence: evidence, - }) - } - edges := make([]types.KnowledgeGraphEdgeData, 0, len(graph.Edges)) - for _, edge := range graph.Edges { - edges = append(edges, types.KnowledgeGraphEdgeData{ - From: edge.From, - To: edge.To, - Relation: edge.Relation, - }) - } - sources := make([]types.BraveSourceData, 0, len(graph.BraveSources)) - for _, src := range graph.BraveSources { - sources = append(sources, types.BraveSourceData{ - Query: src.Query, - Title: src.Title, - URL: src.URL, - Snippet: src.Snippet, - }) - } - return types.KnowledgeGraphData{ - ID: graph.ID, - BrandID: graph.BrandID, - Seed: graph.Seed, - Nodes: nodes, - Edges: edges, - BraveSources: sources, - ExpandStrategy: graph.ExpandStrategy, - PainTagCount: graph.PainTagCount, - GeneratedAt: graph.GeneratedAt, - CreateAt: graph.CreateAt, - UpdateAt: graph.UpdateAt, - } -} diff --git a/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go b/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go index 7acba5e..2057700 100644 --- a/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go +++ b/backend/internal/logic/copy_mission/list_copy_mission_copy_drafts_logic.go @@ -1,40 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type ListCopyMissionCopyDraftsLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewListCopyMissionCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionCopyDraftsLogic { - return &ListCopyMissionCopyDraftsLogic{ctx: ctx, svcCtx: svcCtx} + return &ListCopyMissionCopyDraftsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *ListCopyMissionCopyDraftsLogic) ListCopyMissionCopyDrafts(req *types.CopyMissionPath) (*types.ListCopyMissionCopyDraftsData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } +func (l *ListCopyMissionCopyDraftsLogic) ListCopyMissionCopyDrafts(req *types.CopyMissionPath) (resp *types.ListCopyMissionCopyDraftsData, err error) { + // todo: add your logic here and delete this line - drafts, err := l.svcCtx.CopyDraft.ListByMission(l.ctx, tenantID, uid, personaID, missionID, 50) - if err != nil { - return nil, err - } - list := make([]types.CopyDraftData, 0, len(drafts)) - for _, item := range drafts { - list = append(list, toCopyDraftData(item)) - } - return &types.ListCopyMissionCopyDraftsData{List: list, Total: len(list)}, nil + return } diff --git a/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go b/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go index 6281c70..b21ffcb 100644 --- a/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go +++ b/backend/internal/logic/copy_mission/list_copy_mission_scan_posts_logic.go @@ -1,49 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type ListCopyMissionScanPostsLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewListCopyMissionScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionScanPostsLogic { - return &ListCopyMissionScanPostsLogic{ctx: ctx, svcCtx: svcCtx} + return &ListCopyMissionScanPostsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *ListCopyMissionScanPostsLogic) ListCopyMissionScanPosts( - req *types.ListCopyMissionScanPostsHandlerReq, -) (*types.ListPersonaViralScanPostsData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } +func (l *ListCopyMissionScanPostsLogic) ListCopyMissionScanPosts(req *types.ListCopyMissionScanPostsHandlerReq) (resp *types.ListPersonaViralScanPostsData, err error) { + // todo: add your logic here and delete this line - limit := 100 - if req.Limit > 0 { - limit = req.Limit - } - posts, err := l.svcCtx.ScanPost.ListForPersona(l.ctx, scanpostdomain.PersonaListRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - CopyMissionID: missionID, - Limit: limit, - }) - if err != nil { - return nil, err - } - return toViralScanPostsData(posts), nil + return } diff --git a/backend/internal/logic/copy_mission/list_copy_missions_logic.go b/backend/internal/logic/copy_mission/list_copy_missions_logic.go index f99b9f0..661225f 100644 --- a/backend/internal/logic/copy_mission/list_copy_missions_logic.go +++ b/backend/internal/logic/copy_mission/list_copy_missions_logic.go @@ -1,8 +1,10 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -24,18 +26,8 @@ func NewListCopyMissionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) * } } -func (l *ListCopyMissionsLogic) ListCopyMissions(req *types.PersonaCopyMissionsPath) (*types.ListCopyMissionsData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { - return nil, err - } - result, err := l.svcCtx.CopyMission.List(l.ctx, tenantID, uid, personaID) - if err != nil { - return nil, err - } - return toListData(result), nil +func (l *ListCopyMissionsLogic) ListCopyMissions(req *types.PersonaCopyMissionsPath) (resp *types.ListCopyMissionsData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/mapper.go b/backend/internal/logic/copy_mission/mapper.go deleted file mode 100644 index e791184..0000000 --- a/backend/internal/logic/copy_mission/mapper.go +++ /dev/null @@ -1,300 +0,0 @@ -package copy_mission - -import ( - copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - personadomain "haixun-backend/internal/model/persona/domain/usecase" - scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" - "haixun-backend/internal/types" -) - -func seedMissionResearchFromPersona(persona personadomain.PersonaSummary) (*missionentity.ResearchMap, []string) { - src := persona.CopyResearchMap - if src.AudienceSummary == "" && src.ContentGoal == "" && len(src.SuggestedTags) == 0 { - return nil, nil - } - tags := make([]missionentity.SuggestedTag, 0, len(src.SuggestedTags)) - for _, tag := range src.SuggestedTags { - if tag == "" { - continue - } - tags = append(tags, missionentity.SuggestedTag{Tag: tag}) - } - selected := append([]string(nil), src.SuggestedTags...) - if len(selected) > 6 { - selected = selected[:6] - } - rm := &missionentity.ResearchMap{ - AudienceSummary: src.AudienceSummary, - ContentGoal: src.ContentGoal, - Questions: append([]string(nil), src.Questions...), - Pillars: append([]string(nil), src.Pillars...), - Exclusions: append([]string(nil), src.Exclusions...), - SuggestedTags: tags, - BenchmarkNotes: src.BenchmarkNotes, - } - return rm, selected -} - -func toMissionPatch(req *types.UpdateCopyMissionReq) missiondomain.MissionPatch { - if req == nil { - return missiondomain.MissionPatch{} - } - patch := missiondomain.MissionPatch{ - Label: req.Label, - SeedQuery: req.SeedQuery, - Brief: req.Brief, - } - if req.AudienceSummary != nil { - patch.AudienceSummary = req.AudienceSummary - } - if req.ContentGoal != nil { - patch.ContentGoal = req.ContentGoal - } - if req.Questions != nil { - patch.QuestionsSet = true - patch.Questions = append([]string(nil), req.Questions...) - } - if req.Pillars != nil { - patch.PillarsSet = true - patch.Pillars = append([]string(nil), req.Pillars...) - } - if req.Exclusions != nil { - patch.ExclusionsSet = true - patch.Exclusions = append([]string(nil), req.Exclusions...) - } - if req.KnowledgeItems != nil { - patch.KnowledgeItemsSet = true - patch.KnowledgeItems = append([]string(nil), req.KnowledgeItems...) - } - if req.SelectedKnowledgeItems != nil { - patch.SelectedKnowledgeItemsSet = true - patch.SelectedKnowledgeItems = append([]string(nil), req.SelectedKnowledgeItems...) - } - if req.BenchmarkNotes != nil { - patch.BenchmarkNotes = req.BenchmarkNotes - } - if req.SelectedTags != nil { - patch.SelectedTagsSet = true - patch.SelectedTags = append([]string(nil), req.SelectedTags...) - } - return patch -} - -func toResearchItemData(items []missiondomain.ResearchItemSummary) []types.CopyMissionResearchItemData { - out := make([]types.CopyMissionResearchItemData, 0, len(items)) - for _, item := range items { - out = append(out, types.CopyMissionResearchItemData{ - Title: item.Title, - URL: item.URL, - Snippet: item.Snippet, - Query: item.Query, - }) - } - return out -} - -func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData { - tags := make([]types.CopySuggestedTagData, 0, len(item.ResearchMap.SuggestedTags)) - for _, tag := range item.ResearchMap.SuggestedTags { - tags = append(tags, types.CopySuggestedTagData{ - Tag: tag.Tag, - Reason: tag.Reason, - SearchIntent: tag.SearchIntent, - SearchType: tag.SearchType, - }) - } - accounts := make([]types.CopySimilarAccountData, 0, len(item.ResearchMap.SimilarAccounts)) - for _, acc := range item.ResearchMap.SimilarAccounts { - accounts = append(accounts, types.CopySimilarAccountData{ - Username: acc.Username, - Reason: acc.Reason, - Source: acc.Source, - MatchedSource: append([]string(nil), acc.MatchedSource...), - Confidence: acc.Confidence, - Status: acc.Status, - TopicRelevance: acc.TopicRelevance, - LastSeenAt: acc.LastSeenAt, - ProfileUrl: acc.ProfileURL, - AuthorVerified: acc.AuthorVerified, - FollowerCount: acc.FollowerCount, - EngagementScore: acc.EngagementScore, - LikeCount: acc.LikeCount, - ReplyCount: acc.ReplyCount, - PostCount: acc.PostCount, - }) - } - samples := make([]types.CopyAudienceSampleData, 0, len(item.ResearchMap.AudienceSamples)) - for _, sample := range item.ResearchMap.AudienceSamples { - samples = append(samples, types.CopyAudienceSampleData{ - Username: sample.Username, - SamplePostId: sample.SamplePostID, - SampleText: sample.SampleText, - ReplyLikeCount: sample.ReplyLikeCount, - Appearances: sample.Appearances, - FirstSeenAt: sample.FirstSeenAt, - LastSeenAt: sample.LastSeenAt, - Status: sample.Status, - }) - } - return types.CopyMissionData{ - ID: item.ID, - PersonaID: item.PersonaID, - Label: item.Label, - SeedQuery: item.SeedQuery, - Brief: item.Brief, - ResearchMap: types.CopyMissionResearchMapData{ - AudienceSummary: item.ResearchMap.AudienceSummary, - ContentGoal: item.ResearchMap.ContentGoal, - Questions: append([]string(nil), item.ResearchMap.Questions...), - Pillars: append([]string(nil), item.ResearchMap.Pillars...), - Exclusions: append([]string(nil), item.ResearchMap.Exclusions...), - KnowledgeItems: append([]string(nil), item.ResearchMap.KnowledgeItems...), - SelectedKnowledgeItems: append([]string(nil), item.ResearchMap.SelectedKnowledgeItems...), - ResearchItems: toResearchItemData(item.ResearchMap.ResearchItems), - SuggestedTags: tags, - SimilarAccounts: accounts, - AudienceSamples: samples, - BenchmarkNotes: item.ResearchMap.BenchmarkNotes, - }, - SelectedTags: append([]string(nil), item.SelectedTags...), - LastScanJobID: item.LastScanJobID, - Status: item.Status, - CreateAt: item.CreateAt, - UpdateAt: item.UpdateAt, - } -} - -func toListData(result *missiondomain.ListResult) *types.ListCopyMissionsData { - if result == nil { - return &types.ListCopyMissionsData{List: []types.CopyMissionData{}} - } - list := make([]types.CopyMissionData, 0, len(result.List)) - for _, item := range result.List { - list = append(list, toCopyMissionData(item)) - } - return &types.ListCopyMissionsData{List: list} -} - -func toEntityResearchMap(data types.CopyMissionResearchMapData) missionentity.ResearchMap { - tags := make([]missionentity.SuggestedTag, 0, len(data.SuggestedTags)) - for _, tag := range data.SuggestedTags { - tags = append(tags, missionentity.SuggestedTag{ - Tag: tag.Tag, - Reason: tag.Reason, - SearchIntent: tag.SearchIntent, - SearchType: tag.SearchType, - }) - } - accounts := make([]missionentity.SimilarAccount, 0, len(data.SimilarAccounts)) - for _, acc := range data.SimilarAccounts { - accounts = append(accounts, missionentity.SimilarAccount{ - Username: acc.Username, - Reason: acc.Reason, - Source: acc.Source, - MatchedSource: append([]string(nil), acc.MatchedSource...), - Confidence: acc.Confidence, - Status: acc.Status, - TopicRelevance: acc.TopicRelevance, - LastSeenAt: acc.LastSeenAt, - ProfileURL: acc.ProfileUrl, - AuthorVerified: acc.AuthorVerified, - FollowerCount: acc.FollowerCount, - EngagementScore: acc.EngagementScore, - LikeCount: acc.LikeCount, - ReplyCount: acc.ReplyCount, - PostCount: acc.PostCount, - }) - } - samples := make([]missionentity.AudienceSample, 0, len(data.AudienceSamples)) - for _, sample := range data.AudienceSamples { - samples = append(samples, missionentity.AudienceSample{ - Username: sample.Username, - SamplePostID: sample.SamplePostId, - SampleText: sample.SampleText, - ReplyLikeCount: sample.ReplyLikeCount, - Appearances: sample.Appearances, - FirstSeenAt: sample.FirstSeenAt, - LastSeenAt: sample.LastSeenAt, - Status: sample.Status, - }) - } - return missionentity.ResearchMap{ - AudienceSummary: data.AudienceSummary, - ContentGoal: data.ContentGoal, - Questions: append([]string(nil), data.Questions...), - Pillars: append([]string(nil), data.Pillars...), - Exclusions: append([]string(nil), data.Exclusions...), - KnowledgeItems: append([]string(nil), data.KnowledgeItems...), - SelectedKnowledgeItems: append([]string(nil), data.SelectedKnowledgeItems...), - SuggestedTags: tags, - SimilarAccounts: accounts, - AudienceSamples: samples, - BenchmarkNotes: data.BenchmarkNotes, - } -} - -func toViralScanPostsData(posts []scanpostdomain.ScanPostSummary) *types.ListPersonaViralScanPostsData { - list := make([]types.ViralScanPostData, 0, len(posts)) - for _, post := range posts { - list = append(list, types.ViralScanPostData{ - ID: post.ID, - SearchTag: post.SearchTag, - Permalink: post.Permalink, - Author: post.Author, - AuthorVerified: post.AuthorVerified, - FollowerCount: post.FollowerCount, - Text: post.Text, - LikeCount: post.LikeCount, - ReplyCount: post.ReplyCount, - EngagementScore: post.EngagementScore, - Source: post.Source, - ScanJobID: post.ScanJobID, - Replies: toScanReplyData(post.Replies), - CreateAt: post.CreateAt, - }) - } - return &types.ListPersonaViralScanPostsData{List: list, Total: len(list)} -} - -func toScanReplyData(replies []scanpostdomain.ScanReplySummary) []types.ScanReplyData { - if len(replies) == 0 { - return nil - } - out := make([]types.ScanReplyData, 0, len(replies)) - for _, reply := range replies { - out = append(out, types.ScanReplyData{ - ExternalID: reply.ExternalID, - Author: reply.Author, - Text: reply.Text, - Permalink: reply.Permalink, - LikeCount: reply.LikeCount, - PostedAt: reply.PostedAt, - }) - } - return out -} - -func toCopyDraftData(item copydraftdomain.CopyDraftSummary) types.CopyDraftData { - return types.CopyDraftData{ - ID: item.ID, - PersonaID: item.PersonaID, - CopyMissionID: item.CopyMissionID, - ScanPostID: item.ScanPostID, - 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, - } -} diff --git a/backend/internal/logic/copy_mission/matrix_guard.go b/backend/internal/logic/copy_mission/matrix_guard.go deleted file mode 100644 index 6a3ed9f..0000000 --- a/backend/internal/logic/copy_mission/matrix_guard.go +++ /dev/null @@ -1,42 +0,0 @@ -package copy_mission - -import ( - "context" - "strings" - - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/model/job/domain/enum" - domrepo "haixun-backend/internal/model/job/domain/repository" - "haixun-backend/internal/svc" -) - -func ensureNoActiveMatrixJob(ctx context.Context, svcCtx *svc.ServiceContext, missionID string) error { - if svcCtx == nil || svcCtx.Job == nil { - return nil - } - missionID = strings.TrimSpace(missionID) - if missionID == "" { - return nil - } - runs, _, _, _, _, err := svcCtx.Job.ListRuns(ctx, domrepo.RunListFilter{ - Scope: "copy_mission", - ScopeID: missionID, - Statuses: []enum.RunStatus{ - enum.RunStatusPending, - enum.RunStatusQueued, - enum.RunStatusRunning, - enum.RunStatusWaitingWorker, - enum.RunStatusCancelRequested, - }, - }, 1, 20) - if err != nil { - return err - } - for _, run := range runs { - if run != nil && run.TemplateType == "generate-copy-matrix" { - return app.For(code.Job).ResInvalidState("內容矩陣背景任務進行中,請稍後再試") - } - } - return nil -} diff --git a/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go b/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go index f13a687..7c773a8 100644 --- a/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go +++ b/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go @@ -5,9 +5,7 @@ package copy_mission import ( "context" - "strings" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -28,22 +26,8 @@ func NewPatchCopyMissionAudienceSampleLogic(ctx context.Context, svcCtx *svc.Ser } } -func (l *PatchCopyMissionAudienceSampleLogic) PatchCopyMissionAudienceSample(req *types.PatchAudienceSampleHandlerReq) (*types.CopyMissionData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - item, err := l.svcCtx.CopyMission.PatchAudienceSample(l.ctx, missiondomain.PatchAudienceSampleRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: strings.TrimSpace(req.PersonaID), - MissionID: strings.TrimSpace(req.ID), - Username: strings.TrimSpace(req.Username), - Status: req.Status, - }) - if err != nil { - return nil, err - } - data := toCopyMissionData(*item) - return &data, nil +func (l *PatchCopyMissionAudienceSampleLogic) PatchCopyMissionAudienceSample(req *types.PatchAudienceSampleHandlerReq) (resp *types.CopyMissionData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go b/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go index 8ae223a..1c2598c 100644 --- a/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go +++ b/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go @@ -1,14 +1,11 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - libcopy "haixun-backend/internal/library/copymission" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -29,73 +26,8 @@ func NewPatchCopyMissionGraphNodesLogic(ctx context.Context, svcCtx *svc.Service } } -func (l *PatchCopyMissionGraphNodesLogic) PatchCopyMissionGraphNodes(req *types.PatchCopyMissionGraphNodesHandlerReq) (*types.KnowledgeGraphData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - if len(req.Updates) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("updates is required") - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } +func (l *PatchCopyMissionGraphNodesLogic) PatchCopyMissionGraphNodes(req *types.PatchCopyMissionGraphNodesHandlerReq) (resp *types.KnowledgeGraphData, err error) { + // todo: add your logic here and delete this line - updates := make([]kgusecase.NodeUpdate, 0, len(req.Updates)) - for _, item := range req.Updates { - nodeID := strings.TrimSpace(item.NodeID) - if nodeID == "" { - continue - } - update := kgusecase.NodeUpdate{NodeID: nodeID} - if item.SelectedForScan != nil { - update.SelectedForScan = item.SelectedForScan - } - if item.RelevanceTags != nil { - update.RelevanceTagsSet = true - update.RelevanceTags = append([]string(nil), item.RelevanceTags...) - } - if item.RecencyTags != nil { - update.RecencyTagsSet = true - update.RecencyTags = append([]string(nil), item.RecencyTags...) - } - if update.SelectedForScan == nil && !update.RelevanceTagsSet && !update.RecencyTagsSet { - return nil, app.For(code.Persona).InputMissingRequired("each update needs selected_for_scan or patrol tags") - } - updates = append(updates, update) - } - if len(updates) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("updates is required") - } - - graph, err := l.svcCtx.KnowledgeGraph.UpdateNodes(l.ctx, kgusecase.UpdateNodesRequest{ - TenantID: tenantID, - OwnerUID: uid, - CopyMissionID: missionID, - Updates: updates, - }) - if err != nil { - return nil, err - } - - prev := summaryToEntityResearchMap(mission.ResearchMap) - researchMap := libcopy.BuildResearchMapFromGraph(prev, graph.Nodes, graph.BraveSources) - _, err = l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: personaID, - MissionID: missionID, - Patch: missiondomain.MissionPatch{ - ResearchMap: &researchMap, - }, - }) - if err != nil { - return nil, err - } - - data := toKnowledgeGraphData(graph) - return &data, nil + return } diff --git a/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go b/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go index cf59b9d..b8a0637 100644 --- a/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go +++ b/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go @@ -5,11 +5,7 @@ package copy_mission import ( "context" - "strings" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -30,40 +26,8 @@ func NewPatchCopyMissionSimilarAccountLogic(ctx context.Context, svcCtx *svc.Ser } } -func (l *PatchCopyMissionSimilarAccountLogic) PatchCopyMissionSimilarAccount(req *types.PatchSimilarAccountHandlerReq) (*types.CopyMissionData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - item, err := l.svcCtx.CopyMission.PatchSimilarAccount(l.ctx, missiondomain.PatchSimilarAccountRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: strings.TrimSpace(req.PersonaID), - MissionID: strings.TrimSpace(req.ID), - Username: strings.TrimSpace(req.Username), - Status: req.Status, - }) - if err != nil { - return nil, err - } - if status := strings.TrimSpace(req.Status); status == missionentity.SimilarAccountStatusExcluded || status == missionentity.SimilarAccountStatusRecommended { - if research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid); researchErr == nil { - if memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research); memberErr == nil && memberCtx.ActiveAccountID != "" { - username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@")) - if username != "" { - _ = l.svcCtx.ThreadsAccount.UpsertKnownAccountsFromScan(l.ctx, threadsaccountdomain.UpsertKnownAccountsFromScanRequest{ - TenantID: tenantID, - OwnerUID: uid, - AccountID: memberCtx.ActiveAccountID, - SimilarAccounts: []missionentity.SimilarAccount{{ - Username: username, - Status: status, - }}, - }) - } - } - } - } - data := toCopyMissionData(*item) - return &data, nil +func (l *PatchCopyMissionSimilarAccountLogic) PatchCopyMissionSimilarAccount(req *types.PatchSimilarAccountHandlerReq) (resp *types.CopyMissionData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/research_map_entity.go b/backend/internal/logic/copy_mission/research_map_entity.go deleted file mode 100644 index 53074ae..0000000 --- a/backend/internal/logic/copy_mission/research_map_entity.go +++ /dev/null @@ -1,78 +0,0 @@ -package copy_mission - -import ( - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" -) - -func summaryResearchItemsToEntity(items []missiondomain.ResearchItemSummary) []missionentity.ResearchItem { - out := make([]missionentity.ResearchItem, 0, len(items)) - for _, item := range items { - out = append(out, missionentity.ResearchItem{ - Title: item.Title, - URL: item.URL, - Snippet: item.Snippet, - Query: item.Query, - }) - } - return out -} - -func summaryToEntityResearchMap(summary missiondomain.ResearchMapSummary) missionentity.ResearchMap { - tags := make([]missionentity.SuggestedTag, 0, len(summary.SuggestedTags)) - for _, tag := range summary.SuggestedTags { - tags = append(tags, missionentity.SuggestedTag{ - Tag: tag.Tag, - Reason: tag.Reason, - SearchIntent: tag.SearchIntent, - SearchType: tag.SearchType, - }) - } - accounts := make([]missionentity.SimilarAccount, 0, len(summary.SimilarAccounts)) - for _, acc := range summary.SimilarAccounts { - accounts = append(accounts, missionentity.SimilarAccount{ - Username: acc.Username, - Reason: acc.Reason, - Source: acc.Source, - MatchedSource: append([]string(nil), acc.MatchedSource...), - Confidence: acc.Confidence, - Status: acc.Status, - TopicRelevance: acc.TopicRelevance, - LastSeenAt: acc.LastSeenAt, - ProfileURL: acc.ProfileURL, - AuthorVerified: acc.AuthorVerified, - FollowerCount: acc.FollowerCount, - EngagementScore: acc.EngagementScore, - LikeCount: acc.LikeCount, - ReplyCount: acc.ReplyCount, - PostCount: acc.PostCount, - }) - } - samples := make([]missionentity.AudienceSample, 0, len(summary.AudienceSamples)) - for _, sample := range summary.AudienceSamples { - samples = append(samples, missionentity.AudienceSample{ - Username: sample.Username, - SamplePostID: sample.SamplePostID, - SampleText: sample.SampleText, - ReplyLikeCount: sample.ReplyLikeCount, - Appearances: sample.Appearances, - FirstSeenAt: sample.FirstSeenAt, - LastSeenAt: sample.LastSeenAt, - Status: sample.Status, - }) - } - return missionentity.ResearchMap{ - AudienceSummary: summary.AudienceSummary, - ContentGoal: summary.ContentGoal, - Questions: append([]string(nil), summary.Questions...), - Pillars: append([]string(nil), summary.Pillars...), - Exclusions: append([]string(nil), summary.Exclusions...), - KnowledgeItems: append([]string(nil), summary.KnowledgeItems...), - SelectedKnowledgeItems: append([]string(nil), summary.SelectedKnowledgeItems...), - ResearchItems: summaryResearchItemsToEntity(summary.ResearchItems), - SuggestedTags: tags, - SimilarAccounts: accounts, - AudienceSamples: samples, - BenchmarkNotes: summary.BenchmarkNotes, - } -} diff --git a/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go b/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go index e0d9fcc..cca830a 100644 --- a/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go +++ b/backend/internal/logic/copy_mission/schedule_copy_mission_drafts_logic.go @@ -5,13 +5,7 @@ package copy_mission import ( "context" - "fmt" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/library/publishschedule" - 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" @@ -32,68 +26,8 @@ func NewScheduleCopyMissionDraftsLogic(ctx context.Context, svcCtx *svc.ServiceC } } -func (l *ScheduleCopyMissionDraftsLogic) ScheduleCopyMissionDrafts(req *types.ScheduleCopyMissionDraftsHandlerReq) (resp *types.ScheduleCopyDraftsData, err error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, req.PersonaID, req.ID); err != nil { - return nil, err - } - if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil { - return nil, err - } - times := scheduleTimes(req.StartAt, req.Timezone, req.Slots, len(req.DraftIDs)) - list := make([]types.PublishQueueItemData, 0, len(req.DraftIDs)) - for idx, draftID := range req.DraftIDs { - draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.PersonaID, draftID) - if err != nil { - return nil, err - } - if draft.CopyMissionID != req.ID { - return nil, app.For(code.Persona).InputInvalidFormat("草稿不屬於此文案任務") - } - scheduledAt := int64(0) - if idx < len(times) { - scheduledAt = times[idx] - } - item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{ - TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID, - PersonaID: req.PersonaID, CopyMissionID: req.ID, 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.PersonaID, DraftID: draft.ID, QueueID: item.ID, - }) - list = append(list, publishQueueData(item)) - } - return &types.ScheduleCopyDraftsData{ - Scheduled: len(list), List: list, Message: fmt.Sprintf("已排程 %d 篇草稿", len(list)), - }, nil -} +func (l *ScheduleCopyMissionDraftsLogic) ScheduleCopyMissionDrafts(req *types.ScheduleCopyMissionDraftsHandlerReq) (resp *types.CopyMissionScheduleCopyDraftsData, err error) { + // todo: add your logic here and delete this line -func scheduleTimes(startAt int64, timezone string, slots []types.PublishSlotData, count int) []int64 { - converted := make([]publishschedule.Slot, 0, len(slots)) - for _, slot := range slots { - converted = append(converted, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time}) - } - return publishschedule.BuildSchedule(startAt, timezone, converted, count) -} - -func publishQueueData(item *pqdomain.QueueItemSummary) types.PublishQueueItemData { - if item == nil { - return types.PublishQueueItemData{} - } - return types.PublishQueueItemData{ - ID: item.ID, AccountID: item.AccountID, PersonaID: item.PersonaID, - CopyMissionID: item.CopyMissionID, CopyDraftID: item.CopyDraftID, - Text: item.Text, ScheduledAt: item.ScheduledAt, Status: item.Status, - MediaID: item.MediaID, Permalink: item.Permalink, PublishedAt: item.PublishedAt, - ErrorMessage: item.ErrorMessage, RetryCount: item.RetryCount, LastAttemptAt: item.LastAttemptAt, - NextAttemptAt: item.NextAttemptAt, MissedAt: item.MissedAt, PausedReason: item.PausedReason, - CreateAt: item.CreateAt, UpdateAt: item.UpdateAt, - } + return } diff --git a/backend/internal/logic/copy_mission/schedule_helper.go b/backend/internal/logic/copy_mission/schedule_helper.go deleted file mode 100644 index 23f59d7..0000000 --- a/backend/internal/logic/copy_mission/schedule_helper.go +++ /dev/null @@ -1,49 +0,0 @@ -package copy_mission - -import ( - "context" - - jobentity "haixun-backend/internal/model/job/domain/entity" - "haixun-backend/internal/svc" - "haixun-backend/internal/types" -) - -const viralScanTemplate = "scan-viral" - -func findCopyMissionScanSchedule(ctx context.Context, svcCtx *svc.ServiceContext, missionID string) (*jobentity.Schedule, error) { - schedules, _, _, _, _, err := svcCtx.Job.ListSchedules(ctx, "copy_mission", missionID, 1, 50) - if err != nil { - return nil, err - } - for _, schedule := range schedules { - if schedule != nil && schedule.TemplateType == viralScanTemplate { - return schedule, nil - } - } - return nil, nil -} - -func toCopyMissionScanScheduleData(schedule *jobentity.Schedule, personaID, missionID string) *types.CopyMissionScanScheduleData { - if schedule == nil { - return &types.CopyMissionScanScheduleData{ - PersonaID: personaID, - MissionID: missionID, - Cron: "0 9 * * *", - Timezone: "Asia/Taipei", - Enabled: false, - } - } - data := &types.CopyMissionScanScheduleData{ - ID: schedule.ID.Hex(), - PersonaID: personaID, - MissionID: missionID, - Cron: schedule.Cron, - Timezone: schedule.Timezone, - Enabled: schedule.Enabled, - NextRunAt: schedule.NextRunAt, - } - if schedule.LastRunAt != nil { - data.LastRunAt = *schedule.LastRunAt - } - return data -} diff --git a/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go index ec491ec..05e3fd6 100644 --- a/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go +++ b/backend/internal/logic/copy_mission/start_copy_mission_analyze_job_logic.go @@ -1,67 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "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 StartCopyMissionAnalyzeJobLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewStartCopyMissionAnalyzeJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionAnalyzeJobLogic { - return &StartCopyMissionAnalyzeJobLogic{ctx: ctx, svcCtx: svcCtx} + return &StartCopyMissionAnalyzeJobLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob( - req *types.CopyMissionPath, -) (*types.StartCopyMissionAnalyzeJobData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { - return nil, err - } - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil { - return nil, err - } +func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionAnalyzeJobData, err error) { + // todo: add your logic here and delete this line - payload := map[string]any{ - "tenant_id": tenantID, - "owner_uid": uid, - "persona_id": personaID, - "copy_mission_id": missionID, - } - run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ - TemplateType: "analyze-copy-mission", - Scope: "copy_mission", - ScopeID: missionID, - TenantID: tenantID, - OwnerUID: uid, - Payload: payload, - }) - if err != nil { - return nil, err - } - if run == nil { - return nil, app.For(code.Job).ResInvalidState("analyze job not created") - } - return &types.StartCopyMissionAnalyzeJobData{ - JobID: run.ID.Hex(), - Status: string(run.Status), - Message: "受眾與研究地圖分析已在背景執行,完成後可微調標籤並開始海巡", - }, nil + return } diff --git a/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go index 9d1cd25..48582cb 100644 --- a/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go +++ b/backend/internal/logic/copy_mission/start_copy_mission_copy_draft_job_logic.go @@ -1,80 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/library/style8d" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type StartCopyMissionCopyDraftJobLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewStartCopyMissionCopyDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionCopyDraftJobLogic { - return &StartCopyMissionCopyDraftJobLogic{ctx: ctx, svcCtx: svcCtx} + return &StartCopyMissionCopyDraftJobLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob( - req *types.StartCopyMissionCopyDraftJobHandlerReq, -) (*types.StartCopyMissionCopyDraftJobData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - scanPostID := strings.TrimSpace(req.ScanPostID) - if scanPostID == "" { - return nil, app.For(code.Persona).InputMissingRequired("scan_post_id is required") - } +func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(req *types.StartCopyMissionCopyDraftJobHandlerReq) (resp *types.StartCopyMissionCopyDraftJobData, err error) { + // todo: add your logic here and delete this line - if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - 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 對標分析") - } - if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil { - return nil, err - } - post, err := l.svcCtx.ScanPost.GetForPersona(l.ctx, tenantID, uid, personaID, scanPostID) - if err != nil { - return nil, err - } - if postMission := strings.TrimSpace(post.CopyMissionID); postMission != "" && postMission != missionID { - return nil, app.For(code.Persona).ResInvalidState("爆款不屬於此任務") - } - - payload := map[string]any{ - "persona_id": personaID, - "copy_mission_id": missionID, - "scan_post_id": scanPostID, - } - run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ - TemplateType: "generate-copy-draft", - Scope: "copy_mission", - ScopeID: missionID, - TenantID: tenantID, - OwnerUID: uid, - Payload: payload, - }) - if err != nil { - return nil, err - } - return &types.StartCopyMissionCopyDraftJobData{ - JobID: run.ID.Hex(), - Status: string(run.Status), - Message: "深仿寫已在背景執行", - }, nil + return } diff --git a/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go index 2315e07..31367aa 100644 --- a/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go +++ b/backend/internal/logic/copy_mission/start_copy_mission_matrix_job_logic.go @@ -1,89 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/library/style8d" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type StartCopyMissionMatrixJobLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewStartCopyMissionMatrixJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionMatrixJobLogic { - return &StartCopyMissionMatrixJobLogic{ctx: ctx, svcCtx: svcCtx} + return &StartCopyMissionMatrixJobLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob( - req *types.StartCopyMissionMatrixJobHandlerReq, -) (*types.StartCopyMissionMatrixJobData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } - if mission.Status != string(missionentity.StatusMapped) && - mission.Status != string(missionentity.StatusScanned) && - mission.Status != string(missionentity.StatusDrafted) { - return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣") - } - if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { - return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖") - } +func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(req *types.StartCopyMissionMatrixJobHandlerReq) (resp *types.StartCopyMissionMatrixJobData, err error) { + // todo: add your logic here and delete this line - 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 對標分析") - } - if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil { - return nil, err - } - - count := req.Count - if count <= 0 { - count = 5 - } - if count > 12 { - count = 12 - } - - payload := map[string]any{ - "tenant_id": tenantID, - "owner_uid": uid, - "persona_id": personaID, - "copy_mission_id": missionID, - "count": count, - } - run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ - TemplateType: "generate-copy-matrix", - Scope: "copy_mission", - ScopeID: missionID, - TenantID: tenantID, - OwnerUID: uid, - Payload: payload, - }) - if err != nil { - return nil, err - } - return &types.StartCopyMissionMatrixJobData{ - JobID: run.ID.Hex(), - Status: string(run.Status), - Message: "內容矩陣產出已在背景執行", - }, nil + return } diff --git a/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go b/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go index c4b0fc6..3681e2b 100644 --- a/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go +++ b/backend/internal/logic/copy_mission/start_copy_mission_scan_job_logic.go @@ -1,95 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - libviral "haixun-backend/internal/library/viral" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type StartCopyMissionScanJobLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewStartCopyMissionScanJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionScanJobLogic { - return &StartCopyMissionScanJobLogic{ctx: ctx, svcCtx: svcCtx} + return &StartCopyMissionScanJobLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *StartCopyMissionScanJobLogic) StartCopyMissionScanJob( - req *types.CopyMissionPath, -) (*types.StartCopyMissionScanJobData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { - return nil, err - } - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } - if mission.ResearchMap.AudienceSummary == "" && len(mission.ResearchMap.SuggestedTags) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("請先產生研究地圖") - } - if len(mission.SelectedTags) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("請至少勾選一個搜尋標籤") - } - if len(mission.SelectedTags) > libviral.MaxScanTags { - return nil, app.For(code.Persona).InputMissingRequired("搜尋標籤最多 6 個,請減少勾選以節省搜尋配額") - } +func (l *StartCopyMissionScanJobLogic) StartCopyMissionScanJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionScanJobData, err error) { + // todo: add your logic here and delete this line - research, err := l.svcCtx.Placement.ResearchSettings(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.HasDiscoverPath() { - return nil, app.For(code.Setting).InputMissingRequired("爆款掃描需要 Threads API、Chrome Session 或 Web Search API(請檢查連線模式與搜尋來源)") - } - - if err := l.svcCtx.ScanPost.ClearCopyMissionViralScan(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - if err := l.svcCtx.CopyDraft.ClearByMission(l.ctx, tenantID, uid, personaID, missionID); err != nil { - return nil, err - } - - payload := map[string]any{ - "persona_id": personaID, - "copy_mission_id": missionID, - "keywords": mission.SelectedTags, - "bootstrap": false, - } - for key, value := range memberCtx.PayloadFields() { - payload[key] = value - } - - run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ - TemplateType: "scan-viral", - Scope: "copy_mission", - ScopeID: missionID, - TenantID: tenantID, - OwnerUID: uid, - Payload: payload, - }) - if err != nil { - return nil, err - } - return &types.StartCopyMissionScanJobData{ - JobID: run.ID.Hex(), - Status: string(run.Status), - Message: "已清除舊爆款與產文草稿,海巡已在背景執行", - }, nil + return } diff --git a/backend/internal/logic/copy_mission/update_copy_mission_logic.go b/backend/internal/logic/copy_mission/update_copy_mission_logic.go index ef1c748..c936d7b 100644 --- a/backend/internal/logic/copy_mission/update_copy_mission_logic.go +++ b/backend/internal/logic/copy_mission/update_copy_mission_logic.go @@ -1,14 +1,11 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -29,39 +26,8 @@ func NewUpdateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) } } -func (l *UpdateCopyMissionLogic) UpdateCopyMission(req *types.UpdateCopyMissionHandlerReq) (*types.CopyMissionData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - patch := toMissionPatch(&req.UpdateCopyMissionReq) - if req.Status != nil { - status := strings.TrimSpace(*req.Status) - if status != string(missionentity.StatusArchived) && status != string(missionentity.StatusOpen) { - return nil, app.For(code.Persona).InputMissingRequired("status 僅支援 archived 或 open") - } - st := missionentity.Status(status) - patch.Status = &st - } - item, err := l.svcCtx.CopyMission.Update(l.ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: uid, - PersonaID: strings.TrimSpace(req.PersonaID), - MissionID: strings.TrimSpace(req.ID), - Patch: patch, - }) - if err != nil { - return nil, err - } - if req.Status != nil && strings.TrimSpace(*req.Status) == string(missionentity.StatusArchived) { - if schedule, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, strings.TrimSpace(req.ID)); err == nil && schedule != nil && schedule.Enabled { - disabled := false - _, _ = l.svcCtx.Job.UpdateSchedule(l.ctx, jobdom.UpdateScheduleRequest{ - ID: schedule.ID.Hex(), - Enabled: &disabled, - }) - } - } - data := toCopyMissionData(*item) - return &data, nil +func (l *UpdateCopyMissionLogic) UpdateCopyMission(req *types.UpdateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) { + // todo: add your logic here and delete this line + + return } diff --git a/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go b/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go index 80b2c13..916139a 100644 --- a/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go +++ b/backend/internal/logic/copy_mission/upsert_copy_mission_scan_schedule_logic.go @@ -1,106 +1,33 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + package copy_mission import ( "context" - "strings" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" ) type UpsertCopyMissionScanScheduleLogic struct { + logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewUpsertCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertCopyMissionScanScheduleLogic { - return &UpsertCopyMissionScanScheduleLogic{ctx: ctx, svcCtx: svcCtx} + return &UpsertCopyMissionScanScheduleLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } } -func (l *UpsertCopyMissionScanScheduleLogic) UpsertCopyMissionScanSchedule( - req *types.UpsertCopyMissionScanScheduleHandlerReq, -) (*types.CopyMissionScanScheduleData, error) { - tenantID, uid, err := actorFrom(l.ctx) - if err != nil { - return nil, err - } - personaID := strings.TrimSpace(req.PersonaID) - missionID := strings.TrimSpace(req.ID) - if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { - return nil, err - } - mission, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID) - if err != nil { - return nil, err - } - if mission.Status == string(missionentity.StatusArchived) { - return nil, app.For(code.Persona).ResInvalidState("已封存的任務無法設定排程") - } - if req.Enabled && len(mission.SelectedTags) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("啟用排程前請先勾選並儲存搜尋標籤") - } +func (l *UpsertCopyMissionScanScheduleLogic) UpsertCopyMissionScanSchedule(req *types.UpsertCopyMissionScanScheduleHandlerReq) (resp *types.CopyMissionScanScheduleData, err error) { + // todo: add your logic here and delete this line - cronExpr := "0 9 * * *" - timezone := "Asia/Taipei" - if strings.TrimSpace(req.Cron) != "" { - cronExpr = strings.TrimSpace(req.Cron) - } - if strings.TrimSpace(req.Timezone) != "" { - timezone = strings.TrimSpace(req.Timezone) - } - - research, err := l.svcCtx.Placement.ResearchSettings(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 - } - - payload := map[string]any{ - "persona_id": personaID, - "copy_mission_id": missionID, - "bootstrap": false, - "scheduled": true, - } - for key, value := range memberCtx.PayloadFields() { - payload[key] = value - } - - existing, err := findCopyMissionScanSchedule(l.ctx, l.svcCtx, missionID) - if err != nil { - return nil, err - } - if existing != nil { - updated, err := l.svcCtx.Job.UpdateSchedule(l.ctx, jobdom.UpdateScheduleRequest{ - ID: existing.ID.Hex(), - Cron: cronExpr, - Timezone: timezone, - PayloadTemplate: payload, - Enabled: &req.Enabled, - }) - if err != nil { - return nil, err - } - return toCopyMissionScanScheduleData(updated, personaID, missionID), nil - } - - created, err := l.svcCtx.Job.CreateSchedule(l.ctx, jobdom.CreateScheduleRequest{ - TemplateType: viralScanTemplate, - Scope: "copy_mission", - ScopeID: missionID, - Cron: cronExpr, - Timezone: timezone, - PayloadTemplate: payload, - Enabled: req.Enabled, - }) - if err != nil { - return nil, err - } - return toCopyMissionScanScheduleData(created, personaID, missionID), nil + return } diff --git a/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go b/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go index f252ef8..927fb33 100644 --- a/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go +++ b/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go @@ -90,6 +90,11 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal }) username := strings.TrimPrefix(strings.TrimSpace(req.Username), "@") + input := style8d.AnalyzeInput{ + Mode: style8d.AnalyzeModeBenchmark, + Username: username, + Posts: posts, + } systemPrompt, err := libprompt.Style8DSystem() if err != nil { return nil, app.For(code.AI).SysInternal("prompt config load failed") @@ -102,7 +107,7 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal }, System: systemPrompt, Messages: []domai.Message{ - {Role: "user", Content: style8d.BuildUserPrompt(username, posts)}, + {Role: "user", Content: input.BuildUserPrompt()}, }, }) if err != nil { @@ -119,7 +124,7 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal if err != nil { return nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:" + err.Error()) } - profile := style8d.BuildStoredProfile(username, posts, parsed) + profile := input.BuildStoredProfile(parsed) profileJSON, err := profile.JSON() if err != nil { return nil, err @@ -137,9 +142,10 @@ func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.Anal }) personaDraft := strings.TrimSpace(profile.PersonaDraft) + benchmark := input.StyleBenchmark() patch := personausecase.PersonaPatch{ StyleProfile: &profileJSON, - StyleBenchmark: &username, + StyleBenchmark: &benchmark, } if personaDraft != "" { patch.Persona = &personaDraft diff --git a/backend/internal/logic/persona/content_ops_mapper.go b/backend/internal/logic/persona/content_ops_mapper.go new file mode 100644 index 0000000..595c460 --- /dev/null +++ b/backend/internal/logic/persona/content_ops_mapper.go @@ -0,0 +1,30 @@ +package persona + +import ( + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/types" +) + +func toTopicCandidateData(item contentops.TopicCandidateSummary) types.TopicCandidateData { + return types.TopicCandidateData{ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Source: item.Source, Category: item.Category, SeedQuery: item.SeedQuery, TrendReason: item.TrendReason, TrendKeywords: item.TrendKeywords, HeatScore: item.HeatScore, FitScore: item.FitScore, InteractionScore: item.InteractionScore, ExtendScore: item.ExtendScore, RiskScore: item.RiskScore, FinalScore: item.FinalScore, RecommendedMissions: item.RecommendedMissions, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} + +func toContentPlanData(item contentops.ContentPlanSummary) types.ContentPlanData { + return types.ContentPlanData{ID: item.ID, PersonaID: item.PersonaID, TopicCandidateID: item.TopicCandidateID, Topic: item.Topic, Mission: item.Mission, TargetAudience: item.TargetAudience, Angle: item.Angle, OpeningType: item.OpeningType, BodyType: item.BodyType, Emotion: item.Emotion, EndingType: item.EndingType, CtaType: item.CtaType, RiskLevel: item.RiskLevel, RequiresHumanReview: item.RequiresHumanReview, Avoid: item.Avoid, SelectedKnowledge: item.SelectedKnowledge, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} + +func toFeedbackEventData(item contentops.FeedbackEventSummary) types.FeedbackEventData { + return types.FeedbackEventData{ID: item.ID, PersonaID: item.PersonaID, ContentPlanID: item.ContentPlanID, DraftID: item.DraftID, Decision: item.Decision, Note: item.Note, Snapshot: item.Snapshot, CreateAt: item.CreateAt} +} + +func toKnowledgeSourceData(item contentops.KnowledgeSourceSummary) types.KnowledgeSourceData { + return types.KnowledgeSourceData{ID: item.ID, PersonaID: item.PersonaID, SourceType: item.SourceType, Title: item.Title, Filename: item.Filename, ParsedStatus: item.ParsedStatus, ChunkCount: item.ChunkCount, CreateAt: item.CreateAt} +} + +func toKnowledgeChunkData(item contentops.KnowledgeChunkSummary) types.KnowledgeChunkData { + return types.KnowledgeChunkData{ID: item.ID, PersonaID: item.PersonaID, SourceID: item.SourceID, Content: item.Content, Topics: item.Topics, StyleTags: item.StyleTags, RiskLevel: item.RiskLevel, CreateAt: item.CreateAt} +} + +func toFormulaPoolData(item contentops.FormulaPoolSummary) types.FormulaPoolData { + return types.FormulaPoolData{ID: item.ID, PersonaID: item.PersonaID, Type: item.Type, Name: item.Name, Pattern: item.Pattern, UseCases: item.UseCases, Avoid: item.Avoid, Weight: item.Weight, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} diff --git a/backend/internal/logic/persona/copy_draft_mapper.go b/backend/internal/logic/persona/copy_draft_mapper.go index 3872eca..adf9610 100644 --- a/backend/internal/logic/persona/copy_draft_mapper.go +++ b/backend/internal/logic/persona/copy_draft_mapper.go @@ -7,24 +7,34 @@ import ( 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, + ID: item.ID, + PersonaID: item.PersonaID, + ContentPlanID: item.ContentPlanID, + CopyMissionID: item.CopyMissionID, + ScanPostID: item.ScanPostID, + FormulaID: item.FormulaID, + DraftType: item.DraftType, + SortOrder: item.SortOrder, + Text: item.Text, + TopicTag: item.TopicTag, + Angle: item.Angle, + Hook: item.Hook, + Rationale: item.Rationale, + ReferenceNotes: item.ReferenceNotes, + Sources: item.Sources, + AiScore: item.AiScore, + FormulaScore: item.FormulaScore, + BrandFitScore: item.BrandFitScore, + RiskScore: item.RiskScore, + SimilarityScore: item.SimilarityScore, + EngagementPotential: item.EngagementPotential, + FreshnessScore: item.FreshnessScore, + ReviewSuggestion: item.ReviewSuggestion, + 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/create_content_plan_logic.go b/backend/internal/logic/persona/create_content_plan_logic.go new file mode 100644 index 0000000..b00ddde --- /dev/null +++ b/backend/internal/logic/persona/create_content_plan_logic.go @@ -0,0 +1,41 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateContentPlanLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateContentPlanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateContentPlanLogic { + return &CreateContentPlanLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateContentPlanLogic) CreateContentPlan(req *types.CreateContentPlanHandlerReq) (resp *types.ContentPlanData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, TopicCandidateID: req.TopicCandidateID, Topic: req.Topic, Mission: req.Mission, TargetAudience: req.TargetAudience, Angle: req.Angle, OpeningType: req.OpeningType, BodyType: req.BodyType, Emotion: req.Emotion, EndingType: req.EndingType, CtaType: req.CtaType, RiskLevel: req.RiskLevel, RequiresHumanReview: req.RequiresHumanReview, Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge}) + if err != nil { + return nil, err + } + data := toContentPlanData(*item) + return &data, nil +} diff --git a/backend/internal/logic/persona/create_feedback_event_logic.go b/backend/internal/logic/persona/create_feedback_event_logic.go new file mode 100644 index 0000000..aa1870e --- /dev/null +++ b/backend/internal/logic/persona/create_feedback_event_logic.go @@ -0,0 +1,41 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateFeedbackEventLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateFeedbackEventLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateFeedbackEventLogic { + return &CreateFeedbackEventLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateFeedbackEventLogic) CreateFeedbackEvent(req *types.CreateFeedbackEventHandlerReq) (resp *types.FeedbackEventData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentOps.CreateFeedbackEvent(l.ctx, contentops.FeedbackEventInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, ContentPlanID: req.ContentPlanID, DraftID: req.DraftID, Decision: req.Decision, Note: req.Note, Snapshot: req.Snapshot}) + if err != nil { + return nil, err + } + data := toFeedbackEventData(*item) + return &data, nil +} diff --git a/backend/internal/logic/persona/create_formula_pool_logic.go b/backend/internal/logic/persona/create_formula_pool_logic.go new file mode 100644 index 0000000..b769f8c --- /dev/null +++ b/backend/internal/logic/persona/create_formula_pool_logic.go @@ -0,0 +1,41 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateFormulaPoolLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateFormulaPoolLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateFormulaPoolLogic { + return &CreateFormulaPoolLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateFormulaPoolLogic) CreateFormulaPool(req *types.CreateFormulaPoolHandlerReq) (resp *types.FormulaPoolData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentOps.CreateFormulaPool(l.ctx, contentops.FormulaPoolInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, Type: req.Type, Name: req.Name, Pattern: req.Pattern, UseCases: req.UseCases, Avoid: req.Avoid, Weight: req.Weight}) + if err != nil { + return nil, err + } + data := toFormulaPoolData(*item) + return &data, nil +} diff --git a/backend/internal/logic/persona/create_knowledge_source_logic.go b/backend/internal/logic/persona/create_knowledge_source_logic.go new file mode 100644 index 0000000..4e83e74 --- /dev/null +++ b/backend/internal/logic/persona/create_knowledge_source_logic.go @@ -0,0 +1,41 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateKnowledgeSourceLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateKnowledgeSourceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateKnowledgeSourceLogic { + return &CreateKnowledgeSourceLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateKnowledgeSourceLogic) CreateKnowledgeSource(req *types.CreateKnowledgeSourceHandlerReq) (resp *types.KnowledgeSourceData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentOps.CreateKnowledgeSource(l.ctx, contentops.KnowledgeSourceInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, SourceType: req.SourceType, Title: req.Title, Filename: req.Filename, Content: req.Content, ContentBase64: req.ContentBase64}) + if err != nil { + return nil, err + } + data := toKnowledgeSourceData(*item) + return &data, nil +} diff --git a/backend/internal/logic/persona/generate_from_content_formula_logic.go b/backend/internal/logic/persona/generate_from_content_formula_logic.go index 15a00e7..f0f3bb1 100644 --- a/backend/internal/logic/persona/generate_from_content_formula_logic.go +++ b/backend/internal/logic/persona/generate_from_content_formula_logic.go @@ -13,8 +13,8 @@ import ( "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" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" ) @@ -66,8 +66,8 @@ func (l *GenerateFromContentFormulaLogic) GenerateFromContentFormula(req *types. return nil, err } aiReq := domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, + Provider: providerID, + Model: credential.Model, Credential: domai.Credential{APIKey: credential.APIKey}, } personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) @@ -115,7 +115,7 @@ func (l *GenerateFromContentFormulaLogic) GenerateFromContentFormula(req *types. Formula: *formula, }) if genErr != nil { - return nil, app.For(code.AI).SvcThirdParty("公式生成失敗:" + genErr.Error()) + return nil, app.For(code.AI).SvcThirdParty("貼文生成失敗:" + genErr.Error()) } saved, saveErr := l.svcCtx.CopyDraft.Create(l.ctx, copydraftdomain.CreateRequest{ TenantID: tenantID, @@ -124,6 +124,7 @@ func (l *GenerateFromContentFormulaLogic) GenerateFromContentFormula(req *types. FormulaID: formula.ID, DraftType: copydraftentity.DraftTypeFormula, Text: generated.Text, + TopicTag: generated.TopicTag, Angle: generated.Angle, Hook: generated.Hook, Rationale: generated.Rationale, @@ -137,6 +138,6 @@ func (l *GenerateFromContentFormulaLogic) GenerateFromContentFormula(req *types. } return &types.GenerateFromContentFormulaData{ List: list, - Message: fmt.Sprintf("已依公式產生 %d 篇草稿,已加入內容夾", len(list)), + Message: fmt.Sprintf("已產生 %d 篇可編輯草稿,已加入排程區", len(list)), }, nil -} \ No newline at end of file +} diff --git a/backend/internal/logic/persona/generate_persona_copy_draft_logic.go b/backend/internal/logic/persona/generate_persona_copy_draft_logic.go index eb35709..389d017 100644 --- a/backend/internal/logic/persona/generate_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/generate_persona_copy_draft_logic.go @@ -103,13 +103,15 @@ func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft( } } + replicateTemp := 0.82 result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ APIKey: credential.APIKey, }, - System: libviral.BuildSystemPrompt(), + System: libviral.BuildSystemPrompt(), + Temperature: &replicateTemp, Messages: []domai.Message{ { Role: "user", @@ -121,6 +123,7 @@ func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft( OriginalText: post.Text, AuthorName: post.Author, StructureAnalysis: analysisText, + NarrativeSeed: scanPostID, }), }, }, @@ -153,26 +156,7 @@ func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft( } return &types.GeneratePersonaCopyDraftData{ - Draft: types.CopyDraftData{ - ID: saved.ID, - PersonaID: saved.PersonaID, - CopyMissionID: saved.CopyMissionID, - ScanPostID: saved.ScanPostID, - DraftType: saved.DraftType, - SortOrder: saved.SortOrder, - Text: saved.Text, - Angle: saved.Angle, - Hook: saved.Hook, - Rationale: saved.Rationale, - ReferenceNotes: saved.ReferenceNotes, - Sources: saved.Sources, - Status: saved.Status, - PublishQueueID: saved.PublishQueueID, - PublishedMediaID: saved.PublishedMediaID, - PublishedPermalink: saved.PublishedPermalink, - PublishedAt: saved.PublishedAt, - CreateAt: saved.CreateAt, - }, + Draft: toCopyDraftData(*saved), Message: "已產出仿寫草稿", }, nil } diff --git a/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go b/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go new file mode 100644 index 0000000..7557b97 --- /dev/null +++ b/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go @@ -0,0 +1,65 @@ +package persona + +import ( + "context" + "fmt" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libpersonacopy "haixun-backend/internal/library/personacopy" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type GeneratePersonaTopicMatrixLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGeneratePersonaTopicMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GeneratePersonaTopicMatrixLogic { + return &GeneratePersonaTopicMatrixLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *GeneratePersonaTopicMatrixLogic) GeneratePersonaTopicMatrix(req *types.GeneratePersonaTopicMatrixHandlerReq) (*types.GeneratePersonaTopicMatrixData, 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") + } + + created, err := libpersonacopy.RunTopicMatrix(l.ctx, libpersonacopy.TopicMatrixDeps{ + Persona: l.svcCtx.Persona, + CopyDraft: l.svcCtx.CopyDraft, + ContentOps: l.svcCtx.ContentOps, + ThreadsAccount: l.svcCtx.ThreadsAccount, + Placement: l.svcCtx.Placement, + AI: l.svcCtx.AI, + }, libpersonacopy.TopicMatrixInput{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: personaID, + ContentPlanID: req.ContentPlanID, + Topic: topic, + Brief: req.Brief, + UseWebSearch: req.UseWebSearch, + DraftCount: req.DraftCount, + }, nil) + if err != nil { + return nil, app.For(code.AI).SvcThirdParty("矩陣草稿生成失敗:" + err.Error()) + } + + list := make([]types.CopyDraftData, 0, len(created)) + for _, item := range created { + list = append(list, toCopyDraftData(item)) + } + + return &types.GeneratePersonaTopicMatrixData{ + List: list, + Message: fmt.Sprintf("已產生 %d 篇矩陣草稿", len(list)), + }, nil +} diff --git a/backend/internal/logic/persona/list_content_plans_logic.go b/backend/internal/logic/persona/list_content_plans_logic.go new file mode 100644 index 0000000..9fbe8fd --- /dev/null +++ b/backend/internal/logic/persona/list_content_plans_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListContentPlansLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListContentPlansLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListContentPlansLogic { + return &ListContentPlansLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListContentPlansLogic) ListContentPlans(req *types.PersonaPath) (resp *types.ListContentPlansData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.ContentOps.ListContentPlans(l.ctx, tenantID, uid, req.ID, 50) + if err != nil { + return nil, err + } + list := make([]types.ContentPlanData, 0, len(items)) + for _, item := range items { + list = append(list, toContentPlanData(item)) + } + return &types.ListContentPlansData{List: list}, nil +} diff --git a/backend/internal/logic/persona/list_formula_pools_logic.go b/backend/internal/logic/persona/list_formula_pools_logic.go new file mode 100644 index 0000000..6eabec9 --- /dev/null +++ b/backend/internal/logic/persona/list_formula_pools_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListFormulaPoolsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListFormulaPoolsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListFormulaPoolsLogic { + return &ListFormulaPoolsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListFormulaPoolsLogic) ListFormulaPools(req *types.PersonaPath) (resp *types.ListFormulaPoolsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.ContentOps.ListFormulaPools(l.ctx, tenantID, uid, req.ID, 50) + if err != nil { + return nil, err + } + list := make([]types.FormulaPoolData, 0, len(items)) + for _, item := range items { + list = append(list, toFormulaPoolData(item)) + } + return &types.ListFormulaPoolsData{List: list}, nil +} diff --git a/backend/internal/logic/persona/list_knowledge_chunks_logic.go b/backend/internal/logic/persona/list_knowledge_chunks_logic.go new file mode 100644 index 0000000..110e052 --- /dev/null +++ b/backend/internal/logic/persona/list_knowledge_chunks_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListKnowledgeChunksLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListKnowledgeChunksLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListKnowledgeChunksLogic { + return &ListKnowledgeChunksLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListKnowledgeChunksLogic) ListKnowledgeChunks(req *types.PersonaPath) (resp *types.ListKnowledgeChunksData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.ContentOps.ListKnowledgeChunks(l.ctx, tenantID, uid, req.ID, 80) + if err != nil { + return nil, err + } + list := make([]types.KnowledgeChunkData, 0, len(items)) + for _, item := range items { + list = append(list, toKnowledgeChunkData(item)) + } + return &types.ListKnowledgeChunksData{List: list}, nil +} diff --git a/backend/internal/logic/persona/list_knowledge_sources_logic.go b/backend/internal/logic/persona/list_knowledge_sources_logic.go new file mode 100644 index 0000000..c2ba664 --- /dev/null +++ b/backend/internal/logic/persona/list_knowledge_sources_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListKnowledgeSourcesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListKnowledgeSourcesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListKnowledgeSourcesLogic { + return &ListKnowledgeSourcesLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListKnowledgeSourcesLogic) ListKnowledgeSources(req *types.PersonaPath) (resp *types.ListKnowledgeSourcesData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.ContentOps.ListKnowledgeSources(l.ctx, tenantID, uid, req.ID, 50) + if err != nil { + return nil, err + } + list := make([]types.KnowledgeSourceData, 0, len(items)) + for _, item := range items { + list = append(list, toKnowledgeSourceData(item)) + } + return &types.ListKnowledgeSourcesData{List: list}, nil +} diff --git a/backend/internal/logic/persona/list_persona_content_inbox_logic.go b/backend/internal/logic/persona/list_persona_content_inbox_logic.go index 219849e..ffe2e9c 100644 --- a/backend/internal/logic/persona/list_persona_content_inbox_logic.go +++ b/backend/internal/logic/persona/list_persona_content_inbox_logic.go @@ -5,6 +5,7 @@ import ( "math" 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" ) @@ -55,22 +56,27 @@ func (l *ListPersonaContentInboxLogic) ListPersonaContentInbox(req *types.ListPe queueIDs = append(queueIDs, item.PublishQueueID) } } - queueByID := map[string]int64{} + queueByID := map[string]pqdomain.QueueItemSummary{} 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 + queueByID[q.ID] = q } } } list := make([]types.ContentInboxItemData, 0, len(result.Items)) for _, item := range result.Items { - scheduledAt := queueByID[item.PublishQueueID] + queueItem := queueByID[item.PublishQueueID] + scheduledAt := queueItem.ScheduledAt lifecycle, groupDate := inboxLifecycle(item, scheduledAt) + draftData := toCopyDraftData(item) + if queueItem.TopicTag != "" { + draftData.TopicTag = queueItem.TopicTag + } list = append(list, types.ContentInboxItemData{ - CopyDraftData: toCopyDraftData(item), + CopyDraftData: draftData, ScheduledAt: scheduledAt, Lifecycle: lifecycle, GroupDate: groupDate, @@ -103,4 +109,4 @@ func inboxLifecycle(item copydraftdomain.CopyDraftSummary, scheduledAt int64) (l return "scheduled", item.CreateAt } return "draft", item.CreateAt -} \ No newline at end of file +} diff --git a/backend/internal/logic/persona/list_persona_copy_drafts_logic.go b/backend/internal/logic/persona/list_persona_copy_drafts_logic.go index 3288e38..c3f5042 100644 --- a/backend/internal/logic/persona/list_persona_copy_drafts_logic.go +++ b/backend/internal/logic/persona/list_persona_copy_drafts_logic.go @@ -33,26 +33,7 @@ func (l *ListPersonaCopyDraftsLogic) ListPersonaCopyDrafts(req *types.PersonaPat } list := make([]types.CopyDraftData, 0, len(drafts)) for _, item := range drafts { - list = append(list, types.CopyDraftData{ - ID: item.ID, - PersonaID: item.PersonaID, - CopyMissionID: item.CopyMissionID, - ScanPostID: item.ScanPostID, - 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, - }) + list = append(list, toCopyDraftData(item)) } return &types.ListPersonaCopyDraftsData{List: list, Total: len(list)}, nil } diff --git a/backend/internal/logic/persona/list_topic_candidates_logic.go b/backend/internal/logic/persona/list_topic_candidates_logic.go new file mode 100644 index 0000000..b670d0f --- /dev/null +++ b/backend/internal/logic/persona/list_topic_candidates_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListTopicCandidatesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListTopicCandidatesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListTopicCandidatesLogic { + return &ListTopicCandidatesLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListTopicCandidatesLogic) ListTopicCandidates(req *types.PersonaPath) (resp *types.ListTopicCandidatesData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + items, err := l.svcCtx.ContentOps.ListTopicCandidates(l.ctx, tenantID, uid, req.ID, 50) + if err != nil { + return nil, err + } + list := make([]types.TopicCandidateData, 0, len(items)) + for _, item := range items { + list = append(list, toTopicCandidateData(item)) + } + return &types.ListTopicCandidatesData{List: list}, nil +} diff --git a/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go b/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go new file mode 100644 index 0000000..cfdc3c4 --- /dev/null +++ b/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go @@ -0,0 +1,46 @@ +package persona + +import ( + "context" + "fmt" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type PrunePersonaCopyDraftsLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPrunePersonaCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PrunePersonaCopyDraftsLogic { + return &PrunePersonaCopyDraftsLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *PrunePersonaCopyDraftsLogic) PrunePersonaCopyDrafts(req *types.PrunePersonaCopyDraftsHandlerReq) (*types.PrunePersonaCopyDraftsData, 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 + } + keep := int(req.Keep) + if keep <= 0 { + keep = 40 + } + deleted, err := l.svcCtx.CopyDraft.PruneIdleDrafts(l.ctx, tenantID, uid, personaID, keep) + if err != nil { + return nil, err + } + message := fmt.Sprintf("已保留最近 %d 篇草稿", keep) + if deleted > 0 { + message = fmt.Sprintf("已刪除 %d 篇多餘舊草稿,保留最近 %d 篇", deleted, keep) + } + return &types.PrunePersonaCopyDraftsData{ + Deleted: deleted, + Kept: keep, + Message: message, + }, nil +} diff --git a/backend/internal/logic/persona/publish_persona_copy_draft_logic.go b/backend/internal/logic/persona/publish_persona_copy_draft_logic.go index 7f1d08d..78d08d6 100644 --- a/backend/internal/logic/persona/publish_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/publish_persona_copy_draft_logic.go @@ -59,19 +59,31 @@ func (l *PublishPersonaCopyDraftLogic) PublishPersonaCopyDraft( if err := threadspost.ValidatePublish(text); err != nil { return nil, app.For(code.Persona).InputInvalidFormat(err.Error()) } + topicTag := firstNonEmpty(req.TopicTag, draft.TopicTag) + patch := copydraftdomain.CopyDraftPatch{} + needsUpdate := false if strings.TrimSpace(req.Text) != "" && req.Text != draft.Text { - updatedText, err := l.svcCtx.CopyDraft.Update(l.ctx, copydraftdomain.UpdateRequest{ + patch.Text = &req.Text + needsUpdate = true + } + if strings.TrimSpace(req.TopicTag) != "" && req.TopicTag != draft.TopicTag { + patch.TopicTag = &req.TopicTag + needsUpdate = true + } + if needsUpdate { + updated, err := l.svcCtx.CopyDraft.Update(l.ctx, copydraftdomain.UpdateRequest{ TenantID: tenantID, OwnerUID: uid, PersonaID: personaID, DraftID: draftID, - Patch: copydraftdomain.CopyDraftPatch{Text: &req.Text}, + Patch: patch, }) if err != nil { return nil, err } - draft = updatedText - text = strings.TrimSpace(updatedText.Text) + draft = updated + text = strings.TrimSpace(updated.Text) + topicTag = firstNonEmpty(updated.TopicTag, req.TopicTag) } cred, err := l.svcCtx.ThreadsAccount.ResolveMemberThreadsPublishCredential(l.ctx, tenantID, uid) @@ -82,6 +94,7 @@ func (l *PublishPersonaCopyDraftLogic) PublishPersonaCopyDraft( ThreadsUserID: cred.ThreadsUserID, AccessToken: cred.AccessToken, Text: text, + TopicTag: topicTag, }) if err != nil { return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 發布貼文失敗:" + err.Error()) @@ -94,6 +107,7 @@ func (l *PublishPersonaCopyDraftLogic) PublishPersonaCopyDraft( OwnerUID: uid, AccountID: cred.AccountID, Text: text, + TopicTag: topicTag, MediaID: result.MediaID, Permalink: result.Permalink, PublishedAt: now, diff --git a/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go b/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go index 5f68b53..55cdfca 100644 --- a/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go @@ -35,6 +35,19 @@ func (l *SchedulePersonaCopyDraftLogic) SchedulePersonaCopyDraft(req *types.Sche if err != nil { return nil, err } + if draft.PublishQueueID != "" { + queueItems, err := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, []string{draft.PublishQueueID}) + if err != nil { + return nil, err + } + if len(queueItems) > 0 { + return &types.ScheduleCopyDraftsData{ + Scheduled: 1, + List: []types.PublishQueueItemData{publishQueueData(&queueItems[0])}, + Message: fmt.Sprintf("草稿 %s 已排程", draft.ID), + }, nil + } + } scheduledAt := req.ScheduledAt if scheduledAt <= 0 { scheduledAt = clock.NowUnixNano() @@ -42,17 +55,29 @@ func (l *SchedulePersonaCopyDraftLogic) SchedulePersonaCopyDraft(req *types.Sche 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, + Text: draft.Text, TopicTag: firstNonEmpty(req.TopicTag, draft.TopicTag), ScheduledAt: scheduledAt, }) if err != nil { return nil, err } - _, _ = l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{ + if _, err := l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{ TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, DraftID: draft.ID, QueueID: item.ID, - }) + }); err != nil { + _ = l.svcCtx.PublishQueue.Delete(l.ctx, tenantID, uid, req.AccountID, item.ID) + return nil, err + } return &types.ScheduleCopyDraftsData{ Scheduled: 1, List: []types.PublishQueueItemData{publishQueueData(item)}, Message: fmt.Sprintf("已排程草稿 %s", draft.ID), }, nil -} \ No newline at end of file +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/backend/internal/logic/persona/schedule_persona_drafts_logic.go b/backend/internal/logic/persona/schedule_persona_drafts_logic.go index 2f00096..fe22150 100644 --- a/backend/internal/logic/persona/schedule_persona_drafts_logic.go +++ b/backend/internal/logic/persona/schedule_persona_drafts_logic.go @@ -48,6 +48,16 @@ func (l *SchedulePersonaDraftsLogic) SchedulePersonaDrafts(req *types.SchedulePe if err != nil { return nil, err } + if draft.PublishQueueID != "" { + queueItems, err := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, []string{draft.PublishQueueID}) + if err != nil { + return nil, err + } + if len(queueItems) > 0 { + list = append(list, publishQueueData(&queueItems[0])) + continue + } + } scheduledAt := int64(0) if idx < len(times) { scheduledAt = times[idx] @@ -55,14 +65,17 @@ func (l *SchedulePersonaDraftsLogic) SchedulePersonaDrafts(req *types.SchedulePe 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, + Text: draft.Text, TopicTag: draft.TopicTag, ScheduledAt: scheduledAt, }) if err != nil { return nil, err } - _, _ = l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{ + if _, err := l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{ TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, DraftID: draft.ID, QueueID: item.ID, - }) + }); err != nil { + _ = l.svcCtx.PublishQueue.Delete(l.ctx, tenantID, uid, req.AccountID, item.ID) + return nil, err + } list = append(list, publishQueueData(item)) } return &types.ScheduleCopyDraftsData{ @@ -85,7 +98,7 @@ func publishQueueData(item *pqdomain.QueueItemSummary) types.PublishQueueItemDat return types.PublishQueueItemData{ ID: item.ID, AccountID: item.AccountID, PersonaID: item.PersonaID, CopyMissionID: item.CopyMissionID, CopyDraftID: item.CopyDraftID, - Text: item.Text, ScheduledAt: item.ScheduledAt, Status: item.Status, + Text: item.Text, TopicTag: item.TopicTag, ScheduledAt: item.ScheduledAt, Status: item.Status, MediaID: item.MediaID, Permalink: item.Permalink, PublishedAt: item.PublishedAt, ErrorMessage: item.ErrorMessage, RetryCount: item.RetryCount, LastAttemptAt: item.LastAttemptAt, NextAttemptAt: item.NextAttemptAt, MissedAt: item.MissedAt, PausedReason: item.PausedReason, diff --git a/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go b/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go new file mode 100644 index 0000000..7faa3af --- /dev/null +++ b/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go @@ -0,0 +1,83 @@ +package persona + +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" +) + +type StartPersonaFormulaDraftJobLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPersonaFormulaDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaFormulaDraftJobLogic { + return &StartPersonaFormulaDraftJobLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartPersonaFormulaDraftJobLogic) StartPersonaFormulaDraftJob(req *types.StartPersonaFormulaDraftJobHandlerReq) (*types.StartPersonaFormulaDraftJobData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := strings.TrimSpace(req.ID) + accountID := strings.TrimSpace(req.AccountID) + formulaID := strings.TrimSpace(req.FormulaID) + topic := strings.TrimSpace(req.Topic) + if accountID == "" || formulaID == "" || topic == "" { + return nil, app.For(code.Persona).InputMissingRequired("account_id, formula_id, and topic are required") + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { + return nil, err + } + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + + count := req.DraftCount + if count <= 0 { + count = 1 + } + if count > 5 { + count = 5 + } + + run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ + TemplateType: "generate-formula-draft", + Scope: "persona", + ScopeID: personaID, + TenantID: tenantID, + OwnerUID: uid, + Payload: map[string]any{ + "tenant_id": tenantID, + "owner_uid": uid, + "persona_id": personaID, + "account_id": accountID, + "formula_id": formulaID, + "topic": topic, + "brief": strings.TrimSpace(req.Brief), + "use_web_search": req.UseWebSearch, + "draft_count": count, + "ai_provider": credential.Provider, + "ai_model": credential.Model, + }, + }) + if err != nil { + return nil, err + } + + return &types.StartPersonaFormulaDraftJobData{ + JobID: run.ID.Hex(), + Status: string(run.Status), + AiProvider: credential.Provider, + AiModel: credential.Model, + Message: fmt.Sprintf("貼文改寫已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model), + }, nil +} diff --git a/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go b/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go new file mode 100644 index 0000000..76dee67 --- /dev/null +++ b/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go @@ -0,0 +1,87 @@ +package persona + +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" +) + +type StartPersonaRewriteDraftJobLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPersonaRewriteDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaRewriteDraftJobLogic { + return &StartPersonaRewriteDraftJobLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartPersonaRewriteDraftJobLogic) StartPersonaRewriteDraftJob(req *types.StartPersonaRewriteDraftJobHandlerReq) (*types.StartPersonaRewriteDraftJobData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := strings.TrimSpace(req.ID) + accountID := strings.TrimSpace(req.AccountID) + referenceText := strings.TrimSpace(req.ReferenceText) + topic := strings.TrimSpace(req.Topic) + if accountID == "" || referenceText == "" || topic == "" { + return nil, app.For(code.Persona).InputMissingRequired("account_id, reference_text, and topic are required") + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { + return nil, err + } + if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, accountID); err != nil { + return nil, err + } + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + + count := req.DraftCount + if count <= 0 { + count = 1 + } + if count > 5 { + count = 5 + } + + run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ + TemplateType: "generate-rewrite-draft", + Scope: "persona", + ScopeID: personaID, + TenantID: tenantID, + OwnerUID: uid, + Payload: map[string]any{ + "tenant_id": tenantID, + "owner_uid": uid, + "persona_id": personaID, + "account_id": accountID, + "reference_text": referenceText, + "topic": topic, + "brief": strings.TrimSpace(req.Brief), + "save_label": strings.TrimSpace(req.SaveLabel), + "use_web_search": req.UseWebSearch, + "draft_count": count, + "ai_provider": credential.Provider, + "ai_model": credential.Model, + }, + }) + if err != nil { + return nil, err + } + + return &types.StartPersonaRewriteDraftJobData{ + JobID: run.ID.Hex(), + Status: string(run.Status), + AiProvider: credential.Provider, + AiModel: credential.Model, + Message: fmt.Sprintf("貼文改寫已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model), + }, nil +} diff --git a/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go b/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go new file mode 100644 index 0000000..969013d --- /dev/null +++ b/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go @@ -0,0 +1,152 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libprompt "haixun-backend/internal/library/prompt" + "haixun-backend/internal/library/style8d" + domai "haixun-backend/internal/model/ai/domain/usecase" + aiusecase "haixun-backend/internal/model/ai/usecase" + personausecase "haixun-backend/internal/model/persona/domain/usecase" + tausecase "haixun-backend/internal/model/threads_account/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type StartPersonaStyleAnalysisFromTextLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPersonaStyleAnalysisFromTextLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaStyleAnalysisFromTextLogic { + return &StartPersonaStyleAnalysisFromTextLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *StartPersonaStyleAnalysisFromTextLogic) StartPersonaStyleAnalysisFromText( + req *types.StartPersonaStyleAnalysisFromTextHandlerReq, +) (resp *types.StartPersonaStyleAnalysisFromTextData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if req == nil { + return nil, app.For(code.Persona).InputMissingRequired("request is required") + } + + texts, err := style8d.NormalizeReferenceTexts(req.ReferenceTexts, req.RawText) + if err != nil { + return nil, app.For(code.Persona).InputInvalidFormat(err.Error()) + } + + persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + + activeAccountID := "" + if member, memberErr := l.svcCtx.Member.GetByUID(l.ctx, tenantID, uid); memberErr == nil { + activeAccountID = member.ActiveThreadsAccountID + } + + credential, err := l.resolveResearchCredential(tenantID, uid, activeAccountID) + if err != nil { + return nil, err + } + + providerID, err := aiusecase.MapWorkerProvider(credential.Provider) + if err != nil { + return nil, err + } + + posts := style8d.TextsToPosts(texts) + input := style8d.AnalyzeInput{ + Mode: style8d.AnalyzeModeManual, + SourceLabel: strings.TrimSpace(req.SourceLabel), + Brief: strings.TrimSpace(persona.Brief), + Posts: posts, + } + + systemPrompt, err := libprompt.Style8DSystem() + if err != nil { + return nil, app.For(code.AI).SysInternal("prompt config load failed") + } + 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: input.BuildUserPrompt()}, + }, + }) + if err != nil { + if strings.Contains(err.Error(), "HTTP 401") { + err = app.For(code.AI).SvcThirdParty( + "8D AI 分析授權失敗:請到「設定 > 帳號 AI 設定」確認研究用 provider=" + + credential.Provider + "、model=" + credential.Model + ",並重新貼上對應 provider 的 API key", + ) + } + return nil, err + } + + parsed, err := style8d.ParseLLMOutput(result.Text) + if err != nil { + return nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:" + err.Error()) + } + profile := input.BuildStoredProfile(parsed) + profileJSON, err := profile.JSON() + if err != nil { + return nil, err + } + + benchmark := input.StyleBenchmark() + personaDraft := strings.TrimSpace(profile.PersonaDraft) + patch := personausecase.PersonaPatch{ + StyleProfile: &profileJSON, + StyleBenchmark: &benchmark, + } + if personaDraft != "" { + patch.Persona = &personaDraft + } + updated, err := l.svcCtx.Persona.Update(l.ctx, personausecase.UpdateRequest{ + TenantID: tenantID, + OwnerUID: uid, + PersonaID: req.ID, + Patch: patch, + }) + if err != nil { + return nil, err + } + + out := toPersonaData(*updated) + return &types.StartPersonaStyleAnalysisFromTextData{ + Persona: out, + PostCount: len(posts), + Message: "8D 分析完成,風格檔案已寫入人設", + }, nil +} + +func (l *StartPersonaStyleAnalysisFromTextLogic) resolveResearchCredential( + tenantID, uid, activeAccountID string, +) (*tausecase.WorkerAiCredential, error) { + accountID := strings.TrimSpace(activeAccountID) + if accountID != "" { + return l.svcCtx.ThreadsAccount.ResolveWorkerAiCredential(l.ctx, tenantID, uid, accountID) + } + return l.svcCtx.ThreadsAccount.ResolveMemberResearchAiCredential(l.ctx, tenantID, uid) +} diff --git a/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go b/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go new file mode 100644 index 0000000..acf1f3b --- /dev/null +++ b/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go @@ -0,0 +1,80 @@ +package persona + +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" +) + +type StartPersonaTopicMatrixJobLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPersonaTopicMatrixJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaTopicMatrixJobLogic { + return &StartPersonaTopicMatrixJobLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartPersonaTopicMatrixJobLogic) StartPersonaTopicMatrixJob(req *types.StartPersonaTopicMatrixJobHandlerReq) (*types.StartPersonaTopicMatrixJobData, 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") + } + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { + return nil, err + } + credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + + count := req.DraftCount + if count <= 0 { + count = 5 + } + if count > 5 { + count = 5 + } + + run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ + TemplateType: "generate-topic-matrix", + Scope: "persona", + ScopeID: personaID, + TenantID: tenantID, + OwnerUID: uid, + Payload: map[string]any{ + "tenant_id": tenantID, + "owner_uid": uid, + "persona_id": personaID, + "content_plan_id": strings.TrimSpace(req.ContentPlanID), + "topic": topic, + "brief": strings.TrimSpace(req.Brief), + "use_web_search": req.UseWebSearch, + "draft_count": count, + "ai_provider": credential.Provider, + "ai_model": credential.Model, + }, + }) + if err != nil { + return nil, err + } + + return &types.StartPersonaTopicMatrixJobData{ + JobID: run.ID.Hex(), + Status: string(run.Status), + AiProvider: credential.Provider, + AiModel: credential.Model, + Message: fmt.Sprintf("話題矩陣已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model), + }, nil +} diff --git a/backend/internal/logic/persona/update_content_plan_logic.go b/backend/internal/logic/persona/update_content_plan_logic.go new file mode 100644 index 0000000..97d939a --- /dev/null +++ b/backend/internal/logic/persona/update_content_plan_logic.go @@ -0,0 +1,41 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package persona + +import ( + "context" + + contentops "haixun-backend/internal/model/content_ops/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateContentPlanLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateContentPlanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateContentPlanLogic { + return &UpdateContentPlanLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateContentPlanLogic) UpdateContentPlan(req *types.UpdateContentPlanHandlerReq) (resp *types.ContentPlanData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.ContentOps.UpdateContentPlan(l.ctx, tenantID, uid, req.ID, req.ContentPlanID, contentops.ContentPlanPatch{Topic: req.Topic, Mission: req.Mission, TargetAudience: req.TargetAudience, Angle: req.Angle, OpeningType: req.OpeningType, BodyType: req.BodyType, Emotion: req.Emotion, EndingType: req.EndingType, CtaType: req.CtaType, RiskLevel: req.RiskLevel, RequiresHumanReview: req.RequiresHumanReview, Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge, Status: req.Status}) + if err != nil { + return nil, err + } + data := toContentPlanData(*item) + return &data, nil +} diff --git a/backend/internal/logic/persona/update_persona_copy_draft_logic.go b/backend/internal/logic/persona/update_persona_copy_draft_logic.go index cbe3f2b..5ce3bbb 100644 --- a/backend/internal/logic/persona/update_persona_copy_draft_logic.go +++ b/backend/internal/logic/persona/update_persona_copy_draft_logic.go @@ -45,34 +45,17 @@ func (l *UpdatePersonaCopyDraftLogic) UpdatePersonaCopyDraft( PersonaID: personaID, DraftID: draftID, Patch: copydraftdomain.CopyDraftPatch{ - Text: req.Text, - Hook: req.Hook, - Angle: req.Angle, - Status: req.Status, + Text: req.Text, + TopicTag: req.TopicTag, + Hook: req.Hook, + Angle: req.Angle, + Rationale: req.Rationale, + Status: req.Status, }, }) if err != nil { return nil, err } - data := types.CopyDraftData{ - ID: updated.ID, - PersonaID: updated.PersonaID, - CopyMissionID: updated.CopyMissionID, - ScanPostID: updated.ScanPostID, - DraftType: updated.DraftType, - SortOrder: updated.SortOrder, - Text: updated.Text, - Angle: updated.Angle, - Hook: updated.Hook, - Rationale: updated.Rationale, - ReferenceNotes: updated.ReferenceNotes, - Sources: updated.Sources, - Status: updated.Status, - PublishQueueID: updated.PublishQueueID, - PublishedMediaID: updated.PublishedMediaID, - PublishedPermalink: updated.PublishedPermalink, - PublishedAt: updated.PublishedAt, - CreateAt: updated.CreateAt, - } + data := toCopyDraftData(*updated) return &data, nil } 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 index 89c33a4..fb60458 100644 --- a/backend/internal/logic/threads_account/generate_own_post_formula_logic.go +++ b/backend/internal/logic/threads_account/generate_own_post_formula_logic.go @@ -48,11 +48,9 @@ func (l *GenerateOwnPostFormulaLogic) GenerateOwnPostFormula( 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 - } + data := toOwnPostFormulaReviewData(cached) + data.FromCache = true + return &types.GenerateOwnPostFormulaData{OwnPostFormulaReviewData: data}, nil } } @@ -123,4 +121,4 @@ func (l *GenerateOwnPostFormulaLogic) GenerateOwnPostFormula( data := toOwnPostFormulaReviewData(saved) data.FromCache = false return &types.GenerateOwnPostFormulaData{OwnPostFormulaReviewData: data}, nil -} \ No newline at end of file +} diff --git a/backend/internal/model/content_ops/domain/entity/entity.go b/backend/internal/model/content_ops/domain/entity/entity.go new file mode 100644 index 0000000..5bafaf7 --- /dev/null +++ b/backend/internal/model/content_ops/domain/entity/entity.go @@ -0,0 +1,112 @@ +package entity + +const ( + TopicCandidateCollectionName = "topic_candidates" + ContentPlanCollectionName = "content_plans" + FeedbackEventCollectionName = "feedback_events" + KnowledgeSourceCollectionName = "knowledge_sources" + KnowledgeChunkCollectionName = "knowledge_chunks" + FormulaPoolCollectionName = "formula_pools" +) + +type TopicCandidate struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + Name string `bson:"name"` + Source string `bson:"source,omitempty"` + Category string `bson:"category,omitempty"` + SeedQuery string `bson:"seed_query,omitempty"` + TrendReason string `bson:"trend_reason,omitempty"` + TrendKeywords []string `bson:"trend_keywords,omitempty"` + HeatScore int `bson:"heat_score,omitempty"` + FitScore int `bson:"fit_score,omitempty"` + InteractionScore int `bson:"interaction_score,omitempty"` + ExtendScore int `bson:"extend_score,omitempty"` + RiskScore int `bson:"risk_score,omitempty"` + FinalScore float64 `bson:"final_score,omitempty"` + RecommendedMissions []string `bson:"recommended_missions,omitempty"` + Status string `bson:"status,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +type KnowledgeSource struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + SourceType string `bson:"source_type"` + Title string `bson:"title,omitempty"` + Filename string `bson:"filename,omitempty"` + RawContent string `bson:"raw_content,omitempty"` + ParsedStatus string `bson:"parsed_status,omitempty"` + ChunkCount int `bson:"chunk_count,omitempty"` + CreateAt int64 `bson:"create_at"` +} + +type KnowledgeChunk struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + SourceID string `bson:"source_id"` + Content string `bson:"content"` + Topics []string `bson:"topics,omitempty"` + StyleTags []string `bson:"style_tags,omitempty"` + RiskLevel string `bson:"risk_level,omitempty"` + CreateAt int64 `bson:"create_at"` +} + +type FormulaPool struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + Type string `bson:"type"` + Name string `bson:"name"` + Pattern string `bson:"pattern,omitempty"` + UseCases []string `bson:"use_cases,omitempty"` + Avoid []string `bson:"avoid,omitempty"` + Weight int `bson:"weight,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +type ContentPlan struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + TopicCandidateID string `bson:"topic_candidate_id,omitempty"` + Topic string `bson:"topic"` + Mission string `bson:"mission"` + TargetAudience string `bson:"target_audience,omitempty"` + Angle string `bson:"angle,omitempty"` + OpeningType string `bson:"opening_type,omitempty"` + BodyType string `bson:"body_type,omitempty"` + Emotion string `bson:"emotion,omitempty"` + EndingType string `bson:"ending_type,omitempty"` + CtaType string `bson:"cta_type,omitempty"` + RiskLevel string `bson:"risk_level,omitempty"` + RequiresHumanReview bool `bson:"requires_human_review,omitempty"` + Avoid []string `bson:"avoid,omitempty"` + SelectedKnowledge []string `bson:"selected_knowledge,omitempty"` + Status string `bson:"status,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +type FeedbackEvent struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + ContentPlanID string `bson:"content_plan_id,omitempty"` + DraftID string `bson:"draft_id,omitempty"` + Decision string `bson:"decision"` + Note string `bson:"note,omitempty"` + Snapshot string `bson:"snapshot,omitempty"` + CreateAt int64 `bson:"create_at"` +} diff --git a/backend/internal/model/content_ops/domain/repository/repository.go b/backend/internal/model/content_ops/domain/repository/repository.go new file mode 100644 index 0000000..768f9b8 --- /dev/null +++ b/backend/internal/model/content_ops/domain/repository/repository.go @@ -0,0 +1,24 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/content_ops/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + CreateTopicCandidate(ctx context.Context, item *entity.TopicCandidate) error + ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.TopicCandidate, error) + CreateContentPlan(ctx context.Context, item *entity.ContentPlan) error + UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch map[string]any) (*entity.ContentPlan, error) + GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*entity.ContentPlan, error) + ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.ContentPlan, error) + CreateFeedbackEvent(ctx context.Context, item *entity.FeedbackEvent) error + ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FeedbackEvent, error) + CreateKnowledgeSource(ctx context.Context, item *entity.KnowledgeSource, chunks []*entity.KnowledgeChunk) error + ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeSource, error) + ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeChunk, error) + CreateFormulaPool(ctx context.Context, item *entity.FormulaPool) error + ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FormulaPool, error) +} diff --git a/backend/internal/model/content_ops/domain/usecase/usecase.go b/backend/internal/model/content_ops/domain/usecase/usecase.go new file mode 100644 index 0000000..a715fa4 --- /dev/null +++ b/backend/internal/model/content_ops/domain/usecase/usecase.go @@ -0,0 +1,163 @@ +package usecase + +import "context" + +type TopicCandidateSummary struct { + ID string + PersonaID string + Name string + Source string + Category string + SeedQuery string + TrendReason string + TrendKeywords []string + HeatScore int + FitScore int + InteractionScore int + ExtendScore int + RiskScore int + FinalScore float64 + RecommendedMissions []string + Status string + CreateAt int64 + UpdateAt int64 +} + +type ContentPlanSummary struct { + ID string + PersonaID string + TopicCandidateID string + Topic string + Mission string + TargetAudience string + Angle string + OpeningType string + BodyType string + Emotion string + EndingType string + CtaType string + RiskLevel string + RequiresHumanReview bool + Avoid []string + SelectedKnowledge []string + Status string + CreateAt int64 + UpdateAt int64 +} + +type FeedbackEventSummary struct { + ID string + PersonaID string + ContentPlanID string + DraftID string + Decision string + Note string + Snapshot string + CreateAt int64 +} + +type KnowledgeSourceSummary struct { + ID, PersonaID, SourceType, Title, Filename, ParsedStatus string + ChunkCount int + CreateAt int64 +} +type KnowledgeChunkSummary struct { + ID, PersonaID, SourceID, Content, RiskLevel string + Topics, StyleTags []string + CreateAt int64 +} +type FormulaPoolSummary struct { + ID, PersonaID, Type, Name, Pattern string + UseCases, Avoid []string + Weight int + CreateAt, UpdateAt int64 +} + +type KnowledgeSourceInput struct{ TenantID, OwnerUID, PersonaID, SourceType, Title, Filename, Content, ContentBase64 string } +type FormulaPoolInput struct { + TenantID, OwnerUID, PersonaID, Type, Name, Pattern string + UseCases, Avoid []string + Weight int +} + +type TopicCandidateInput struct { + TenantID string + OwnerUID string + PersonaID string + Name string + Source string + Category string + SeedQuery string + TrendReason string + TrendKeywords []string + HeatScore int + FitScore int + InteractionScore int + ExtendScore int + RiskScore int + RecommendedMissions []string +} + +type ContentPlanInput struct { + TenantID string + OwnerUID string + PersonaID string + TopicCandidateID string + Topic string + Mission string + TargetAudience string + Angle string + OpeningType string + BodyType string + Emotion string + EndingType string + CtaType string + RiskLevel string + RequiresHumanReview bool + Avoid []string + SelectedKnowledge []string +} + +type ContentPlanPatch struct { + Topic *string + Mission *string + TargetAudience *string + Angle *string + OpeningType *string + BodyType *string + Emotion *string + EndingType *string + CtaType *string + RiskLevel *string + RequiresHumanReview *bool + Avoid []string + SelectedKnowledge []string + Status *string +} + +type FeedbackEventInput struct { + TenantID string + OwnerUID string + PersonaID string + ContentPlanID string + DraftID string + Decision string + Note string + Snapshot string +} + +type UseCase interface { + CreateTopicCandidate(ctx context.Context, req TopicCandidateInput) (*TopicCandidateSummary, error) + ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]TopicCandidateSummary, error) + CreateContentPlan(ctx context.Context, req ContentPlanInput) (*ContentPlanSummary, error) + UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch ContentPlanPatch) (*ContentPlanSummary, error) + GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*ContentPlanSummary, error) + ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]ContentPlanSummary, error) + CreateFeedbackEvent(ctx context.Context, req FeedbackEventInput) (*FeedbackEventSummary, error) + ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]FeedbackEventSummary, error) + CreateKnowledgeSource(ctx context.Context, req KnowledgeSourceInput) (*KnowledgeSourceSummary, error) + ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]KnowledgeSourceSummary, error) + ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]KnowledgeChunkSummary, error) + CreateFormulaPool(ctx context.Context, req FormulaPoolInput) (*FormulaPoolSummary, error) + ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]FormulaPoolSummary, error) +} diff --git a/backend/internal/model/content_ops/repository/mongo.go b/backend/internal/model/content_ops/repository/mongo.go new file mode 100644 index 0000000..9444d99 --- /dev/null +++ b/backend/internal/model/content_ops/repository/mongo.go @@ -0,0 +1,232 @@ +package repository + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/content_ops/domain/entity" + domrepo "haixun-backend/internal/model/content_ops/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + topics *mongo.Collection + plans *mongo.Collection + feedback *mongo.Collection + sources *mongo.Collection + chunks *mongo.Collection + formulas *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{ + topics: db.Collection(entity.TopicCandidateCollectionName), + plans: db.Collection(entity.ContentPlanCollectionName), + feedback: db.Collection(entity.FeedbackEventCollectionName), + sources: db.Collection(entity.KnowledgeSourceCollectionName), + chunks: db.Collection(entity.KnowledgeChunkCollectionName), + formulas: db.Collection(entity.FormulaPoolCollectionName), + } +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.topics == nil || r.plans == nil || r.feedback == nil || r.sources == nil || r.chunks == nil || r.formulas == nil { + return nil + } + if _, err := r.topics.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}}); err != nil { + return err + } + if _, err := r.plans.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "topic_candidate_id", Value: 1}}}}); err != nil { + return err + } + _, err := r.feedback.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "content_plan_id", Value: 1}}}, {Keys: bson.D{{Key: "draft_id", Value: 1}}}}) + if err != nil { + return err + } + if _, err := r.sources.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}}); err != nil { + return err + } + if _, err := r.chunks.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "source_id", Value: 1}}}}); err != nil { + return err + } + _, err = r.formulas.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "type", Value: 1}}}}) + return err +} + +func personaFilter(tenantID, ownerUID, personaID string) bson.M { + return bson.M{"tenant_id": strings.TrimSpace(tenantID), "owner_uid": strings.TrimSpace(ownerUID), "persona_id": strings.TrimSpace(personaID)} +} + +func normalizeLimit(limit int) int64 { + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + return int64(limit) +} + +func (r *mongoRepository) CreateTopicCandidate(ctx context.Context, item *entity.TopicCandidate) error { + if r.topics == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + _, err := r.topics.InsertOne(ctx, item) + return err +} + +func (r *mongoRepository) ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.TopicCandidate, error) { + if r.topics == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.topics.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.TopicCandidate + return out, cur.All(ctx, &out) +} + +func (r *mongoRepository) CreateContentPlan(ctx context.Context, item *entity.ContentPlan) error { + if r.plans == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + _, err := r.plans.InsertOne(ctx, item) + return err +} + +func (r *mongoRepository) UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch map[string]any) (*entity.ContentPlan, error) { + if r.plans == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["_id"] = strings.TrimSpace(id) + var out entity.ContentPlan + err := r.plans.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, options.FindOneAndUpdate().SetReturnDocument(options.After)).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("找不到內容計畫") + } + return &out, err +} + +func (r *mongoRepository) GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*entity.ContentPlan, error) { + if r.plans == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["_id"] = strings.TrimSpace(id) + var out entity.ContentPlan + err := r.plans.FindOne(ctx, filter).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Persona).ResNotFound("找不到內容計畫") + } + return &out, err +} + +func (r *mongoRepository) ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.ContentPlan, error) { + if r.plans == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.plans.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.ContentPlan + return out, cur.All(ctx, &out) +} + +func (r *mongoRepository) CreateFeedbackEvent(ctx context.Context, item *entity.FeedbackEvent) error { + if r.feedback == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + _, err := r.feedback.InsertOne(ctx, item) + return err +} + +func (r *mongoRepository) ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FeedbackEvent, error) { + if r.feedback == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.feedback.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.FeedbackEvent + return out, cur.All(ctx, &out) +} + +func (r *mongoRepository) CreateKnowledgeSource(ctx context.Context, item *entity.KnowledgeSource, chunks []*entity.KnowledgeChunk) error { + if r.sources == nil || r.chunks == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if _, err := r.sources.InsertOne(ctx, item); err != nil { + return err + } + if len(chunks) == 0 { + return nil + } + docs := make([]any, 0, len(chunks)) + for _, c := range chunks { + docs = append(docs, c) + } + _, err := r.chunks.InsertMany(ctx, docs) + return err +} + +func (r *mongoRepository) ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeSource, error) { + if r.sources == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.sources.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.KnowledgeSource + return out, cur.All(ctx, &out) +} + +func (r *mongoRepository) ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeChunk, error) { + if r.chunks == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.chunks.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.KnowledgeChunk + return out, cur.All(ctx, &out) +} + +func (r *mongoRepository) CreateFormulaPool(ctx context.Context, item *entity.FormulaPool) error { + if r.formulas == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + _, err := r.formulas.InsertOne(ctx, item) + return err +} + +func (r *mongoRepository) ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FormulaPool, error) { + if r.formulas == nil { + return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + cur, err := r.formulas.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "weight", Value: -1}, {Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit))) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var out []entity.FormulaPool + return out, cur.All(ctx, &out) +} diff --git a/backend/internal/model/content_ops/usecase/usecase.go b/backend/internal/model/content_ops/usecase/usecase.go new file mode 100644 index 0000000..898c0b8 --- /dev/null +++ b/backend/internal/model/content_ops/usecase/usecase.go @@ -0,0 +1,415 @@ +package usecase + +import ( + "archive/zip" + "bytes" + "context" + "encoding/base64" + "encoding/xml" + "math" + "path/filepath" + "regexp" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/content_ops/domain/entity" + domrepo "haixun-backend/internal/model/content_ops/domain/repository" + domusecase "haixun-backend/internal/model/content_ops/domain/usecase" + + "github.com/google/uuid" +) + +type useCase struct{ repo domrepo.Repository } + +func NewUseCase(repo domrepo.Repository) domusecase.UseCase { return &useCase{repo: repo} } + +func requireActor(tenantID, ownerUID, personaID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" || strings.TrimSpace(personaID) == "" { + return app.For(code.Persona).InputMissingRequired("tenant_id, owner_uid, and persona_id are required") + } + return nil +} + +func clampScore(n int) int { + if n < 0 { + return 0 + } + if n > 100 { + return 100 + } + return n +} + +func finalScore(heat, fit, interaction, extend, risk int) float64 { + score := float64(clampScore(heat))*0.25 + float64(clampScore(fit))*0.30 + float64(clampScore(interaction))*0.15 + float64(clampScore(extend))*0.15 + 70*0.10 - float64(clampScore(risk))*0.20 + return math.Round(score*10) / 10 +} + +func (u *useCase) CreateTopicCandidate(ctx context.Context, req domusecase.TopicCandidateInput) (*domusecase.TopicCandidateSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, app.For(code.Persona).InputMissingRequired("topic name is required") + } + now := clock.NowUnixNano() + item := &entity.TopicCandidate{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Name: name, Source: strings.TrimSpace(req.Source), Category: strings.TrimSpace(req.Category), SeedQuery: strings.TrimSpace(req.SeedQuery), TrendReason: strings.TrimSpace(req.TrendReason), TrendKeywords: req.TrendKeywords, HeatScore: clampScore(req.HeatScore), FitScore: clampScore(req.FitScore), InteractionScore: clampScore(req.InteractionScore), ExtendScore: clampScore(req.ExtendScore), RiskScore: clampScore(req.RiskScore), RecommendedMissions: req.RecommendedMissions, Status: "candidate", CreateAt: now, UpdateAt: now} + item.FinalScore = finalScore(item.HeatScore, item.FitScore, item.InteractionScore, item.ExtendScore, item.RiskScore) + if err := u.repo.CreateTopicCandidate(ctx, item); err != nil { + return nil, err + } + out := topicSummary(*item) + return &out, nil +} + +func (u *useCase) ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.TopicCandidateSummary, error) { + items, err := u.repo.ListTopicCandidates(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.TopicCandidateSummary, 0, len(items)) + for _, item := range items { + out = append(out, topicSummary(item)) + } + return out, nil +} + +func (u *useCase) CreateContentPlan(ctx context.Context, req domusecase.ContentPlanInput) (*domusecase.ContentPlanSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + if strings.TrimSpace(req.Topic) == "" || strings.TrimSpace(req.Mission) == "" { + return nil, app.For(code.Persona).InputMissingRequired("topic and mission are required") + } + now := clock.NowUnixNano() + item := &entity.ContentPlan{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, TopicCandidateID: strings.TrimSpace(req.TopicCandidateID), Topic: strings.TrimSpace(req.Topic), Mission: strings.TrimSpace(req.Mission), TargetAudience: strings.TrimSpace(req.TargetAudience), Angle: strings.TrimSpace(req.Angle), OpeningType: strings.TrimSpace(req.OpeningType), BodyType: strings.TrimSpace(req.BodyType), Emotion: strings.TrimSpace(req.Emotion), EndingType: strings.TrimSpace(req.EndingType), CtaType: strings.TrimSpace(req.CtaType), RiskLevel: normalizeRisk(req.RiskLevel), RequiresHumanReview: req.RequiresHumanReview || normalizeRisk(req.RiskLevel) != "low", Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge, Status: "planned", CreateAt: now, UpdateAt: now} + if err := u.repo.CreateContentPlan(ctx, item); err != nil { + return nil, err + } + out := planSummary(*item) + return &out, nil +} + +func (u *useCase) UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch domusecase.ContentPlanPatch) (*domusecase.ContentPlanSummary, error) { + m := map[string]any{"update_at": clock.NowUnixNano()} + setString := func(key string, value *string) { + if value != nil { + m[key] = strings.TrimSpace(*value) + } + } + setString("topic", patch.Topic) + setString("mission", patch.Mission) + setString("target_audience", patch.TargetAudience) + setString("angle", patch.Angle) + setString("opening_type", patch.OpeningType) + setString("body_type", patch.BodyType) + setString("emotion", patch.Emotion) + setString("ending_type", patch.EndingType) + setString("cta_type", patch.CtaType) + setString("status", patch.Status) + if patch.RiskLevel != nil { + m["risk_level"] = normalizeRisk(*patch.RiskLevel) + } + if patch.RequiresHumanReview != nil { + m["requires_human_review"] = *patch.RequiresHumanReview + } + if patch.Avoid != nil { + m["avoid"] = patch.Avoid + } + if patch.SelectedKnowledge != nil { + m["selected_knowledge"] = patch.SelectedKnowledge + } + item, err := u.repo.UpdateContentPlan(ctx, tenantID, ownerUID, personaID, id, m) + if err != nil { + return nil, err + } + out := planSummary(*item) + return &out, nil +} + +func (u *useCase) GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*domusecase.ContentPlanSummary, error) { + item, err := u.repo.GetContentPlan(ctx, tenantID, ownerUID, personaID, id) + if err != nil { + return nil, err + } + out := planSummary(*item) + return &out, nil +} + +func (u *useCase) ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.ContentPlanSummary, error) { + items, err := u.repo.ListContentPlans(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.ContentPlanSummary, 0, len(items)) + for _, item := range items { + out = append(out, planSummary(item)) + } + return out, nil +} + +func (u *useCase) CreateFeedbackEvent(ctx context.Context, req domusecase.FeedbackEventInput) (*domusecase.FeedbackEventSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + decision := strings.TrimSpace(req.Decision) + if decision == "" { + return nil, app.For(code.Persona).InputMissingRequired("decision is required") + } + item := &entity.FeedbackEvent{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, ContentPlanID: strings.TrimSpace(req.ContentPlanID), DraftID: strings.TrimSpace(req.DraftID), Decision: decision, Note: strings.TrimSpace(req.Note), Snapshot: strings.TrimSpace(req.Snapshot), CreateAt: clock.NowUnixNano()} + if err := u.repo.CreateFeedbackEvent(ctx, item); err != nil { + return nil, err + } + out := feedbackSummary(*item) + return &out, nil +} + +func (u *useCase) ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FeedbackEventSummary, error) { + items, err := u.repo.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.FeedbackEventSummary, 0, len(items)) + for _, item := range items { + out = append(out, feedbackSummary(item)) + } + return out, nil +} + +func (u *useCase) CreateKnowledgeSource(ctx context.Context, req domusecase.KnowledgeSourceInput) (*domusecase.KnowledgeSourceSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + raw, err := decodeSourceContent(req) + if err != nil { + return nil, app.For(code.Persona).InputInvalidFormat(err.Error()) + } + chunksText := splitChunks(raw, 900) + now := clock.NowUnixNano() + sourceID := uuid.NewString() + item := &entity.KnowledgeSource{ID: sourceID, TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceType: strings.TrimSpace(req.SourceType), Title: strings.TrimSpace(req.Title), Filename: strings.TrimSpace(req.Filename), RawContent: raw, ParsedStatus: "parsed", ChunkCount: len(chunksText), CreateAt: now} + chunks := make([]*entity.KnowledgeChunk, 0, len(chunksText)) + for _, text := range chunksText { + chunks = append(chunks, &entity.KnowledgeChunk{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceID: sourceID, Content: text, Topics: inferTopics(text), StyleTags: inferStyleTags(text), RiskLevel: inferRisk(text), CreateAt: now}) + } + if err := u.repo.CreateKnowledgeSource(ctx, item, chunks); err != nil { + return nil, err + } + out := sourceSummary(*item) + return &out, nil +} + +func (u *useCase) ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeSourceSummary, error) { + items, err := u.repo.ListKnowledgeSources(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.KnowledgeSourceSummary, 0, len(items)) + for _, item := range items { + out = append(out, sourceSummary(item)) + } + return out, nil +} + +func (u *useCase) ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeChunkSummary, error) { + items, err := u.repo.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + out := make([]domusecase.KnowledgeChunkSummary, 0, len(items)) + for _, item := range items { + out = append(out, chunkSummary(item)) + } + return out, nil +} + +func (u *useCase) CreateFormulaPool(ctx context.Context, req domusecase.FormulaPoolInput) (*domusecase.FormulaPoolSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Name) == "" { + return nil, app.For(code.Persona).InputMissingRequired("type and name are required") + } + now := clock.NowUnixNano() + weight := req.Weight + if weight <= 0 { + weight = 50 + } + item := &entity.FormulaPool{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Type: strings.TrimSpace(req.Type), Name: strings.TrimSpace(req.Name), Pattern: strings.TrimSpace(req.Pattern), UseCases: req.UseCases, Avoid: req.Avoid, Weight: weight, CreateAt: now, UpdateAt: now} + if err := u.repo.CreateFormulaPool(ctx, item); err != nil { + return nil, err + } + out := formulaSummary(*item) + return &out, nil +} + +func (u *useCase) ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FormulaPoolSummary, error) { + items, err := u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit) + if err != nil { + return nil, err + } + if len(items) == 0 { + _ = u.seedDefaultFormulas(ctx, tenantID, ownerUID, personaID) + items, _ = u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit) + } + out := make([]domusecase.FormulaPoolSummary, 0, len(items)) + for _, item := range items { + out = append(out, formulaSummary(item)) + } + return out, nil +} + +func (u *useCase) seedDefaultFormulas(ctx context.Context, tenantID, ownerUID, personaID string) error { + defs := []domusecase.FormulaPoolInput{{Type: "opening", Name: "問題開場", Pattern: "用讀者正在問的具體問題開場", UseCases: []string{"互動文"}, Weight: 70}, {Type: "body", Name: "先共鳴再整理", Pattern: "先接住情緒,再整理 2-3 個可執行觀點", UseCases: []string{"共鳴文", "知識文"}, Weight: 75}, {Type: "emotion", Name: "被理解感", Pattern: "講出讀者不一定說出口的焦慮或委屈", UseCases: []string{"情緒共鳴文"}, Weight: 70}, {Type: "cta", Name: "經驗募集型", Pattern: "邀請讀者補充自己的經驗,不硬導購", UseCases: []string{"互動文"}, Weight: 65}} + for _, def := range defs { + def.TenantID = tenantID + def.OwnerUID = ownerUID + def.PersonaID = personaID + if _, err := u.CreateFormulaPool(ctx, def); err != nil { + return err + } + } + return nil +} + +func decodeSourceContent(req domusecase.KnowledgeSourceInput) (string, error) { + if strings.TrimSpace(req.ContentBase64) != "" { + data, err := base64.StdEncoding.DecodeString(req.ContentBase64) + if err != nil { + return "", err + } + if strings.EqualFold(filepath.Ext(req.Filename), ".xlsx") { + return parseXLSX(data) + } + return string(data), nil + } + return strings.TrimSpace(req.Content), nil +} + +type xlsxText struct { + T string `xml:",chardata"` +} + +func parseXLSX(data []byte) (string, error) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return "", err + } + var b strings.Builder + for _, f := range zr.File { + if !strings.HasPrefix(f.Name, "xl/sharedStrings") && !strings.HasPrefix(f.Name, "xl/worksheets") { + continue + } + rc, err := f.Open() + if err != nil { + continue + } + var texts []xlsxText + _ = xml.NewDecoder(rc).Decode(&texts) + _ = rc.Close() + for _, t := range texts { + if s := strings.TrimSpace(t.T); s != "" { + b.WriteString(s) + b.WriteString("\n") + } + } + } + text := strings.TrimSpace(b.String()) + if text == "" { + return "", nil + } + return text, nil +} + +func splitChunks(raw string, max int) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + paras := strings.Split(raw, "\n") + var out []string + var b strings.Builder + for _, p := range paras { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if b.Len()+len([]rune(p)) > max && b.Len() > 0 { + out = append(out, b.String()) + b.Reset() + } + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(p) + } + if b.Len() > 0 { + out = append(out, b.String()) + } + return out +} + +var topicRE = regexp.MustCompile(`[A-Za-z0-9_\p{Han}]{2,12}`) + +func inferTopics(text string) []string { + seen := map[string]bool{} + var out []string + for _, m := range topicRE.FindAllString(text, 20) { + if !seen[m] && len(out) < 8 { + seen[m] = true + out = append(out, m) + } + } + return out +} +func inferStyleTags(text string) []string { + var out []string + if strings.Contains(text, "我") { + out = append(out, "第一人稱") + } + if strings.Contains(text, "?") || strings.Contains(text, "?") { + out = append(out, "提問") + } + if strings.Contains(text, "。") { + out = append(out, "自然標點") + } + return out +} +func inferRisk(text string) string { + lower := strings.ToLower(text) + for _, k := range []string{"醫", "藥", "amh", "投資", "法律"} { + if strings.Contains(lower, k) { + return "medium" + } + } + return "low" +} + +func normalizeRisk(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "high" || raw == "low" { + return raw + } + return "medium" +} +func topicSummary(item entity.TopicCandidate) domusecase.TopicCandidateSummary { + return domusecase.TopicCandidateSummary{ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Source: item.Source, Category: item.Category, SeedQuery: item.SeedQuery, TrendReason: item.TrendReason, TrendKeywords: item.TrendKeywords, HeatScore: item.HeatScore, FitScore: item.FitScore, InteractionScore: item.InteractionScore, ExtendScore: item.ExtendScore, RiskScore: item.RiskScore, FinalScore: item.FinalScore, RecommendedMissions: item.RecommendedMissions, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} +func planSummary(item entity.ContentPlan) domusecase.ContentPlanSummary { + return domusecase.ContentPlanSummary{ID: item.ID, PersonaID: item.PersonaID, TopicCandidateID: item.TopicCandidateID, Topic: item.Topic, Mission: item.Mission, TargetAudience: item.TargetAudience, Angle: item.Angle, OpeningType: item.OpeningType, BodyType: item.BodyType, Emotion: item.Emotion, EndingType: item.EndingType, CtaType: item.CtaType, RiskLevel: item.RiskLevel, RequiresHumanReview: item.RequiresHumanReview, Avoid: item.Avoid, SelectedKnowledge: item.SelectedKnowledge, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} +func feedbackSummary(item entity.FeedbackEvent) domusecase.FeedbackEventSummary { + return domusecase.FeedbackEventSummary{ID: item.ID, PersonaID: item.PersonaID, ContentPlanID: item.ContentPlanID, DraftID: item.DraftID, Decision: item.Decision, Note: item.Note, Snapshot: item.Snapshot, CreateAt: item.CreateAt} +} +func sourceSummary(item entity.KnowledgeSource) domusecase.KnowledgeSourceSummary { + return domusecase.KnowledgeSourceSummary{ID: item.ID, PersonaID: item.PersonaID, SourceType: item.SourceType, Title: item.Title, Filename: item.Filename, ParsedStatus: item.ParsedStatus, ChunkCount: item.ChunkCount, CreateAt: item.CreateAt} +} +func chunkSummary(item entity.KnowledgeChunk) domusecase.KnowledgeChunkSummary { + return domusecase.KnowledgeChunkSummary{ID: item.ID, PersonaID: item.PersonaID, SourceID: item.SourceID, Content: item.Content, Topics: item.Topics, StyleTags: item.StyleTags, RiskLevel: item.RiskLevel, CreateAt: item.CreateAt} +} +func formulaSummary(item entity.FormulaPool) domusecase.FormulaPoolSummary { + return domusecase.FormulaPoolSummary{ID: item.ID, PersonaID: item.PersonaID, Type: item.Type, Name: item.Name, Pattern: item.Pattern, UseCases: item.UseCases, Avoid: item.Avoid, Weight: item.Weight, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt} +} diff --git a/backend/internal/model/copy_draft/domain/entity/draft.go b/backend/internal/model/copy_draft/domain/entity/draft.go index 71d3395..2fb9a47 100644 --- a/backend/internal/model/copy_draft/domain/entity/draft.go +++ b/backend/internal/model/copy_draft/domain/entity/draft.go @@ -10,26 +10,36 @@ const ( ) type CopyDraft struct { - ID string `bson:"_id"` - TenantID string `bson:"tenant_id"` - OwnerUID string `bson:"owner_uid"` - 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"` - Text string `bson:"text"` - Angle string `bson:"angle,omitempty"` - Hook string `bson:"hook,omitempty"` - Rationale string `bson:"rationale,omitempty"` - ReferenceNotes string `bson:"reference_notes,omitempty"` - Sources []string `bson:"sources,omitempty"` - Status string `bson:"status,omitempty"` - PublishQueueID string `bson:"publish_queue_id,omitempty"` - PublishedMediaID string `bson:"published_media_id,omitempty"` - PublishedPermalink string `bson:"published_permalink,omitempty"` - PublishedAt int64 `bson:"published_at,omitempty"` - CreateAt int64 `bson:"create_at"` + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + PersonaID string `bson:"persona_id"` + ContentPlanID string `bson:"content_plan_id,omitempty"` + 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"` + Text string `bson:"text"` + TopicTag string `bson:"topic_tag,omitempty"` + Angle string `bson:"angle,omitempty"` + Hook string `bson:"hook,omitempty"` + Rationale string `bson:"rationale,omitempty"` + ReferenceNotes string `bson:"reference_notes,omitempty"` + Sources []string `bson:"sources,omitempty"` + AiScore int `bson:"ai_score,omitempty"` + FormulaScore int `bson:"formula_score,omitempty"` + BrandFitScore int `bson:"brand_fit_score,omitempty"` + RiskScore int `bson:"risk_score,omitempty"` + SimilarityScore int `bson:"similarity_score,omitempty"` + EngagementPotential int `bson:"engagement_potential,omitempty"` + FreshnessScore int `bson:"freshness_score,omitempty"` + ReviewSuggestion string `bson:"review_suggestion,omitempty"` + Status string `bson:"status,omitempty"` + PublishQueueID string `bson:"publish_queue_id,omitempty"` + PublishedMediaID string `bson:"published_media_id,omitempty"` + PublishedPermalink string `bson:"published_permalink,omitempty"` + PublishedAt int64 `bson:"published_at,omitempty"` + CreateAt int64 `bson:"create_at"` } diff --git a/backend/internal/model/copy_draft/domain/repository/repository.go b/backend/internal/model/copy_draft/domain/repository/repository.go index 031ad7c..61d35ce 100644 --- a/backend/internal/model/copy_draft/domain/repository/repository.go +++ b/backend/internal/model/copy_draft/domain/repository/repository.go @@ -40,4 +40,6 @@ type Repository interface { tenantID, ownerUID, personaID, missionID string, drafts []*entity.CopyDraft, ) error + // PruneOldestPendingBeyond deletes oldest idle pending drafts (no mission / queue / publish) beyond keep. + PruneOldestPendingBeyond(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error) } diff --git a/backend/internal/model/copy_draft/domain/usecase/usecase.go b/backend/internal/model/copy_draft/domain/usecase/usecase.go index c9a74f7..ac717cd 100644 --- a/backend/internal/model/copy_draft/domain/usecase/usecase.go +++ b/backend/internal/model/copy_draft/domain/usecase/usecase.go @@ -5,25 +5,35 @@ import ( ) type CopyDraftSummary struct { - ID string - PersonaID string - CopyMissionID string - ScanPostID string - FormulaID string - DraftType string - SortOrder int - Text string - Angle string - Hook string - Rationale string - ReferenceNotes string - Sources []string - Status string - PublishQueueID string - PublishedMediaID string - PublishedPermalink string - PublishedAt int64 - CreateAt int64 + ID string + PersonaID string + ContentPlanID string + CopyMissionID string + ScanPostID string + FormulaID string + DraftType string + SortOrder int + Text string + TopicTag string + Angle string + Hook string + Rationale string + ReferenceNotes string + Sources []string + AiScore int + FormulaScore int + BrandFitScore int + RiskScore int + SimilarityScore int + EngagementPotential int + FreshnessScore int + ReviewSuggestion string + Status string + PublishQueueID string + PublishedMediaID string + PublishedPermalink string + PublishedAt int64 + CreateAt int64 } type MarkPublishedRequest struct { @@ -44,20 +54,30 @@ type MarkScheduledRequest struct { } type CreateRequest struct { - TenantID string - OwnerUID string - PersonaID string - CopyMissionID string - ScanPostID string - FormulaID string - DraftType string - SortOrder int - Text string - Angle string - Hook string - Rationale string - ReferenceNotes string - Sources []string + TenantID string + OwnerUID string + PersonaID string + ContentPlanID string + CopyMissionID string + ScanPostID string + FormulaID string + DraftType string + SortOrder int + Text string + TopicTag string + Angle string + Hook string + Rationale string + ReferenceNotes string + Sources []string + AiScore int + FormulaScore int + BrandFitScore int + RiskScore int + SimilarityScore int + EngagementPotential int + FreshnessScore int + ReviewSuggestion string } type CreateManyRequest struct { @@ -68,10 +88,12 @@ type CreateManyRequest struct { } type CopyDraftPatch struct { - Text *string - Hook *string - Angle *string - Status *string + Text *string + TopicTag *string + Hook *string + Angle *string + Rationale *string + Status *string } type UpdateRequest struct { @@ -116,6 +138,7 @@ type UseCase interface { 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) + PruneIdleDrafts(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, 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 59d2f5d..aa15a4d 100644 --- a/backend/internal/model/copy_draft/repository/mongo.go +++ b/backend/internal/model/copy_draft/repository/mongo.go @@ -39,6 +39,15 @@ func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { {Key: "create_at", Value: -1}, }, }, + { + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "owner_uid", Value: 1}, + {Key: "persona_id", Value: 1}, + {Key: "content_plan_id", Value: 1}, + {Key: "create_at", Value: -1}, + }, + }, { Keys: bson.D{ {Key: "tenant_id", Value: 1}, @@ -429,6 +438,68 @@ func (r *mongoRepository) replaceMissionMatrixInsertFirst( return err } +func pendingIdleDraftFilter(tenantID, ownerUID, personaID string) bson.M { + filter := personaFilter(tenantID, ownerUID, personaID) + filter["$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}}}}, + {"$or": []bson.M{{"copy_mission_id": ""}, {"copy_mission_id": bson.M{"$exists": false}}}}, + } + return filter +} + +func (r *mongoRepository) PruneOldestPendingBeyond(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error) { + if r.collection == nil { + return 0, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + if keep <= 0 { + keep = 40 + } + filter := pendingIdleDraftFilter(tenantID, ownerUID, personaID) + total, err := r.collection.CountDocuments(ctx, filter) + if err != nil { + return 0, err + } + excess := total - int64(keep) + if excess <= 0 { + return 0, nil + } + opts := options.Find(). + SetSort(bson.D{{Key: "create_at", Value: 1}}). + SetLimit(excess). + SetProjection(bson.M{"_id": 1}) + cur, err := r.collection.Find(ctx, filter, opts) + if err != nil { + return 0, err + } + defer cur.Close(ctx) + ids := make([]string, 0, excess) + for cur.Next(ctx) { + var row struct { + ID string `bson:"_id"` + } + if err := cur.Decode(&row); err != nil { + return 0, err + } + if row.ID != "" { + ids = append(ids, row.ID) + } + } + if err := cur.Err(); err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, nil + } + deleteFilter := personaFilter(tenantID, ownerUID, personaID) + deleteFilter["_id"] = bson.M{"$in": ids} + res, err := r.collection.DeleteMany(ctx, deleteFilter) + if err != nil { + return 0, err + } + return res.DeletedCount, nil +} + func (r *mongoRepository) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID 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 a3097fc..e757cef 100644 --- a/backend/internal/model/copy_draft/usecase/usecase.go +++ b/backend/internal/model/copy_draft/usecase/usecase.go @@ -8,6 +8,7 @@ import ( libcopy "haixun-backend/internal/library/copymission" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/library/threadspost" "haixun-backend/internal/model/copy_draft/domain/entity" domrepo "haixun-backend/internal/model/copy_draft/domain/repository" domusecase "haixun-backend/internal/model/copy_draft/domain/usecase" @@ -16,6 +17,8 @@ import ( goredis "github.com/redis/go-redis/v9" ) +const defaultIdleDraftKeep = 40 + type copyDraftUseCase struct { repo domrepo.Repository redis *goredis.Client @@ -29,7 +32,7 @@ func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequ if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { return nil, err } - text := strings.TrimSpace(req.Text) + text := threadspost.FormatDraftText(req.Text) if text == "" { return nil, app.For(code.Persona).InputMissingRequired("draft text is required") } @@ -38,27 +41,38 @@ func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequ draftType = entity.DraftTypeViralReplica } item := &entity.CopyDraft{ - ID: uuid.NewString(), - TenantID: req.TenantID, - OwnerUID: req.OwnerUID, - 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, - Angle: strings.TrimSpace(req.Angle), - Hook: strings.TrimSpace(req.Hook), - Rationale: strings.TrimSpace(req.Rationale), - ReferenceNotes: strings.TrimSpace(req.ReferenceNotes), - Sources: req.Sources, - Status: "pending", - CreateAt: clock.NowUnixNano(), + ID: uuid.NewString(), + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + PersonaID: req.PersonaID, + ContentPlanID: strings.TrimSpace(req.ContentPlanID), + CopyMissionID: strings.TrimSpace(req.CopyMissionID), + ScanPostID: strings.TrimSpace(req.ScanPostID), + FormulaID: strings.TrimSpace(req.FormulaID), + DraftType: draftType, + SortOrder: req.SortOrder, + Text: text, + TopicTag: normalizeTopicTag(req.TopicTag), + Angle: strings.TrimSpace(req.Angle), + Hook: strings.TrimSpace(req.Hook), + Rationale: strings.TrimSpace(req.Rationale), + ReferenceNotes: strings.TrimSpace(req.ReferenceNotes), + Sources: req.Sources, + AiScore: clampScore(req.AiScore), + FormulaScore: clampScore(req.FormulaScore), + BrandFitScore: clampScore(req.BrandFitScore), + RiskScore: clampScore(req.RiskScore), + SimilarityScore: clampScore(req.SimilarityScore), + EngagementPotential: clampScore(req.EngagementPotential), + FreshnessScore: clampScore(req.FreshnessScore), + ReviewSuggestion: strings.TrimSpace(req.ReviewSuggestion), + Status: "pending", + CreateAt: clock.NowUnixNano(), } if err := u.repo.Create(ctx, item); err != nil { return nil, err } + u.pruneIdleDraftsBestEffort(ctx, req.TenantID, req.OwnerUID, req.PersonaID) summary := toSummary(*item) return &summary, nil } @@ -72,7 +86,7 @@ func (u *copyDraftUseCase) CreateMany(ctx context.Context, req domusecase.Create } items := make([]*entity.CopyDraft, 0, len(req.Drafts)) for idx, draftReq := range req.Drafts { - text := strings.TrimSpace(draftReq.Text) + text := threadspost.FormatDraftText(draftReq.Text) if text == "" { continue } @@ -85,22 +99,32 @@ func (u *copyDraftUseCase) CreateMany(ctx context.Context, req domusecase.Create sortOrder = idx + 1 } items = append(items, &entity.CopyDraft{ - ID: uuid.NewString(), - TenantID: req.TenantID, - OwnerUID: req.OwnerUID, - PersonaID: req.PersonaID, - CopyMissionID: strings.TrimSpace(draftReq.CopyMissionID), - ScanPostID: strings.TrimSpace(draftReq.ScanPostID), - DraftType: draftType, - SortOrder: sortOrder, - Text: text, - Angle: strings.TrimSpace(draftReq.Angle), - Hook: strings.TrimSpace(draftReq.Hook), - Rationale: strings.TrimSpace(draftReq.Rationale), - ReferenceNotes: strings.TrimSpace(draftReq.ReferenceNotes), - Sources: draftReq.Sources, - Status: "pending", - CreateAt: clock.NowUnixNano(), + ID: uuid.NewString(), + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + PersonaID: req.PersonaID, + ContentPlanID: strings.TrimSpace(draftReq.ContentPlanID), + CopyMissionID: strings.TrimSpace(draftReq.CopyMissionID), + ScanPostID: strings.TrimSpace(draftReq.ScanPostID), + DraftType: draftType, + SortOrder: sortOrder, + Text: text, + TopicTag: normalizeTopicTag(draftReq.TopicTag), + Angle: strings.TrimSpace(draftReq.Angle), + Hook: strings.TrimSpace(draftReq.Hook), + Rationale: strings.TrimSpace(draftReq.Rationale), + ReferenceNotes: strings.TrimSpace(draftReq.ReferenceNotes), + Sources: draftReq.Sources, + AiScore: clampScore(draftReq.AiScore), + FormulaScore: clampScore(draftReq.FormulaScore), + BrandFitScore: clampScore(draftReq.BrandFitScore), + RiskScore: clampScore(draftReq.RiskScore), + SimilarityScore: clampScore(draftReq.SimilarityScore), + EngagementPotential: clampScore(draftReq.EngagementPotential), + FreshnessScore: clampScore(draftReq.FreshnessScore), + ReviewSuggestion: strings.TrimSpace(draftReq.ReviewSuggestion), + Status: "pending", + CreateAt: clock.NowUnixNano(), }) } if len(items) == 0 { @@ -109,6 +133,7 @@ func (u *copyDraftUseCase) CreateMany(ctx context.Context, req domusecase.Create if err := u.repo.CreateMany(ctx, items); err != nil { return nil, err } + u.pruneIdleDraftsBestEffort(ctx, req.TenantID, req.OwnerUID, req.PersonaID) out := make([]domusecase.CopyDraftSummary, 0, len(items)) for _, item := range items { out = append(out, toSummary(*item)) @@ -144,16 +169,22 @@ func (u *copyDraftUseCase) Update(ctx context.Context, req domusecase.UpdateRequ } patch["text"] = text } + if req.Patch.TopicTag != nil { + patch["topic_tag"] = normalizeTopicTag(*req.Patch.TopicTag) + } if req.Patch.Hook != nil { patch["hook"] = strings.TrimSpace(*req.Patch.Hook) } if req.Patch.Angle != nil { patch["angle"] = strings.TrimSpace(*req.Patch.Angle) } + if req.Patch.Rationale != nil { + patch["rationale"] = strings.TrimSpace(*req.Patch.Rationale) + } if req.Patch.Status != nil { status := strings.TrimSpace(*req.Patch.Status) - if status != "" && status != "pending" && status != "ready" && status != "scheduled" { - return nil, app.For(code.Persona).InputMissingRequired("status must be pending, ready or scheduled") + if status != "" && status != "pending" && status != "ready" && status != "rejected" && status != "scheduled" { + return nil, app.For(code.Persona).InputMissingRequired("status must be pending, ready, rejected or scheduled") } if status != "" { patch["status"] = status @@ -259,7 +290,7 @@ func buildMatrixDraftEntities( now := clock.NowUnixNano() items := make([]*entity.CopyDraft, 0, len(drafts)) for idx, draftReq := range drafts { - text := strings.TrimSpace(draftReq.Text) + text := threadspost.FormatDraftText(draftReq.Text) if text == "" { continue } @@ -278,6 +309,7 @@ func buildMatrixDraftEntities( MatrixBatchID: batchID, SortOrder: sortOrder, Text: text, + TopicTag: normalizeTopicTag(draftReq.TopicTag), Angle: strings.TrimSpace(draftReq.Angle), Hook: strings.TrimSpace(draftReq.Hook), Rationale: strings.TrimSpace(draftReq.Rationale), @@ -385,6 +417,23 @@ func (u *copyDraftUseCase) List(ctx context.Context, tenantID, ownerUID, persona return out, nil } +func (u *copyDraftUseCase) PruneIdleDrafts(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error) { + if err := requireActor(tenantID, ownerUID, personaID); err != nil { + return 0, err + } + if keep <= 0 { + keep = defaultIdleDraftKeep + } + if keep > 200 { + keep = 200 + } + return u.repo.PruneOldestPendingBeyond(ctx, tenantID, ownerUID, personaID, keep) +} + +func (u *copyDraftUseCase) pruneIdleDraftsBestEffort(ctx context.Context, tenantID, ownerUID, personaID string) { + _, _ = u.repo.PruneOldestPendingBeyond(ctx, tenantID, ownerUID, personaID, defaultIdleDraftKeep) +} + 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 @@ -419,26 +468,57 @@ func requireActor(tenantID, ownerUID, personaID string) error { return nil } +func clampScore(score int) int { + if score < 0 { + return 0 + } + if score > 100 { + return 100 + } + return score +} + func toSummary(item entity.CopyDraft) domusecase.CopyDraftSummary { return domusecase.CopyDraftSummary{ - 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, + ID: item.ID, + PersonaID: item.PersonaID, + ContentPlanID: item.ContentPlanID, + CopyMissionID: item.CopyMissionID, + ScanPostID: item.ScanPostID, + FormulaID: item.FormulaID, + DraftType: item.DraftType, + SortOrder: item.SortOrder, + Text: item.Text, + TopicTag: item.TopicTag, + Angle: item.Angle, + Hook: item.Hook, + Rationale: item.Rationale, + ReferenceNotes: item.ReferenceNotes, + Sources: item.Sources, + AiScore: item.AiScore, + FormulaScore: item.FormulaScore, + BrandFitScore: item.BrandFitScore, + RiskScore: item.RiskScore, + SimilarityScore: item.SimilarityScore, + EngagementPotential: item.EngagementPotential, + FreshnessScore: item.FreshnessScore, + ReviewSuggestion: item.ReviewSuggestion, + Status: item.Status, + PublishQueueID: item.PublishQueueID, + PublishedMediaID: item.PublishedMediaID, + PublishedPermalink: item.PublishedPermalink, + PublishedAt: item.PublishedAt, + CreateAt: item.CreateAt, } } + +func normalizeTopicTag(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "#") + value = strings.TrimSpace(value) + if len([]rune(value)) > 40 { + runes := []rune(value) + value = string(runes[:40]) + } + return value +} diff --git a/backend/internal/svc/job_workers.go b/backend/internal/svc/job_workers.go new file mode 100644 index 0000000..f0e6e2d --- /dev/null +++ b/backend/internal/svc/job_workers.go @@ -0,0 +1,62 @@ +package svc + +import ( + "context" + "fmt" + "log" + + jobworker "haixun-backend/internal/worker/job" +) + +var inlineCopyWorkerTypes = []string{ + "go-generate-topic-matrix", + "go-generate-formula-draft", + "go-generate-rewrite-draft", +} + +func registerCopyJobHandlers(runner *jobworker.Runner, sc *ServiceContext) { + if runner == nil || sc == nil { + return + } + jobworker.RegisterGenerateTopicMatrixHandler(runner, jobworker.GenerateTopicMatrixDeps{ + Jobs: sc.Job, + Persona: sc.Persona, + CopyDraft: sc.CopyDraft, + ContentOps: sc.ContentOps, + ThreadsAccount: sc.ThreadsAccount, + Placement: sc.Placement, + AI: sc.AI, + }) + jobworker.RegisterGenerateFormulaDraftHandler(runner, jobworker.GenerateFormulaDraftDeps{ + Jobs: sc.Job, + Persona: sc.Persona, + ContentFormula: sc.ContentFormula, + CopyDraft: sc.CopyDraft, + ThreadsAccount: sc.ThreadsAccount, + Placement: sc.Placement, + AI: sc.AI, + }) + jobworker.RegisterGenerateRewriteDraftHandler(runner, jobworker.GenerateRewriteDraftDeps{ + Jobs: sc.Job, + Persona: sc.Persona, + ContentFormula: sc.ContentFormula, + CopyDraft: sc.CopyDraft, + ThreadsAccount: sc.ThreadsAccount, + Placement: sc.Placement, + AI: sc.AI, + }) +} + +func (sc *ServiceContext) startInlineCopyWorkers(ctx context.Context, hostname string) { + if sc == nil || sc.Redis == nil { + return + } + for _, workerType := range inlineCopyWorkerTypes { + workerType := workerType + workerID := fmt.Sprintf("%s-inline-%s", hostname, workerType) + runner := jobworker.NewRunner(workerID, workerType, sc.Job) + registerCopyJobHandlers(runner, sc) + go runner.Start(ctx) + log.Printf("inline copy job worker started: id=%s type=%s", workerID, workerType) + } +} diff --git a/backend/internal/svc/service_context.go b/backend/internal/svc/service_context.go index cf00719..a1596be 100644 --- a/backend/internal/svc/service_context.go +++ b/backend/internal/svc/service_context.go @@ -22,12 +22,15 @@ import ( branddomain "haixun-backend/internal/model/brand/domain/usecase" brandrepo "haixun-backend/internal/model/brand/repository" brandusecase "haixun-backend/internal/model/brand/usecase" - 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" + 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" + contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase" + contentopsrepo "haixun-backend/internal/model/content_ops/repository" + contentopsusecase "haixun-backend/internal/model/content_ops/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" @@ -117,6 +120,7 @@ type ServiceContext struct { ScanPost scanpostdomain.UseCase OutreachDraft outreachdraftdomain.UseCase ContentMatrix cmatrixdomain.UseCase + ContentOps contentopsdomain.UseCase CopyDraft copydraftdomain.UseCase ThreadsAccount threadsaccountdomain.UseCase CrmContact crmcontactdomain.UseCase @@ -223,31 +227,24 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := jobUseCase.EnsureScanViralTemplate(ctx); err != nil { panic(err) } - if err := jobUseCase.EnsureAnalyzeCopyMissionTemplate(ctx); err != nil { - panic(err) - } - if err := jobUseCase.EnsureExpandCopyMissionGraphTemplate(ctx); err != nil { - panic(err) - } - if err := jobUseCase.EnsureGenerateCopyMatrixTemplate(ctx); err != nil { - panic(err) - } - if err := jobUseCase.EnsureGenerateCopyDraftTemplate(ctx); err != nil { - panic(err) - } if err := jobUseCase.EnsureGenerateOutreachDraftTemplate(ctx); err != nil { panic(err) } + if err := jobUseCase.EnsureGenerateTopicMatrixTemplate(ctx); err != nil { + panic(err) + } + if err := jobUseCase.EnsureGenerateFormulaDraftTemplate(ctx); err != nil { + panic(err) + } + if err := jobUseCase.EnsureGenerateRewriteDraftTemplate(ctx); err != nil { + panic(err) + } if err := jobUseCase.EnsureRefreshThreadsTokenTemplate(ctx); err != nil { panic(err) } if err := jobUseCase.EnsurePublishAnalyticsTemplate(ctx); err != nil { panic(err) } - if err := jobUseCase.EnsureRefillPublishInventoryTemplate(ctx); err != nil { - panic(err) - } - copyMissionRepository := copymissionrepo.NewMongoRepository(mongoClient.Database()) if err := copyMissionRepository.EnsureIndexes(ctx); err != nil { panic(err) @@ -315,6 +312,7 @@ func NewServiceContext(c config.Config) *ServiceContext { stylePresetRepository := stylepresetrepo.NewMongoRepository(mongoClient.Database()) ownPostFormulaRepository := ownpostformularepo.NewMongoRepository(mongoClient.Database()) contentFormulaRepository := contentformularepo.NewMongoRepository(mongoClient.Database()) + contentOpsRepository := contentopsrepo.NewMongoRepository(mongoClient.Database()) mentionInboxRepository := mentioninboxrepo.NewMongoRepository(mongoClient.Database()) var threadsOAuthStateStore threadsaccountrepodomain.OAuthStateStore if redisClient != nil { @@ -356,11 +354,15 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := contentFormulaRepository.EnsureIndexes(ctx); err != nil { panic(err) } + if err := contentOpsRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } if err := mentionInboxRepository.EnsureIndexes(ctx); err != nil { panic(err) } ownPostFormulaUseCase := ownpostformuladomain.NewUseCase(ownPostFormulaRepository) contentFormulaUseCase := contentformulausecase.NewUseCase(contentFormulaRepository) + contentOpsUseCase := contentopsusecase.NewUseCase(contentOpsRepository) threadsAccountUseCase := threadsaccountusecase.NewUseCase( threadsAccountRepository, threadsAccountSecretsRepository, @@ -421,6 +423,7 @@ func NewServiceContext(c config.Config) *ServiceContext { ScanPost: scanPostUseCase, OutreachDraft: outreachDraftUseCase, ContentMatrix: contentMatrixUseCase, + ContentOps: contentOpsUseCase, CopyDraft: copyDraftUseCase, ThreadsAccount: threadsAccountUseCase, CrmContact: crmContactUseCase, @@ -476,42 +479,6 @@ func NewServiceContext(c config.Config) *ServiceContext { Placement: placementUseCase, AI: sc.AI, }) - jobworker.RegisterAnalyzeCopyMissionHandler(runner, jobworker.AnalyzeCopyMissionDeps{ - Jobs: jobUseCase, - CopyMission: copyMissionUseCase, - Persona: personaUseCase, - ThreadsAccount: threadsAccountUseCase, - Placement: placementUseCase, - AI: sc.AI, - }) - jobworker.RegisterExpandCopyMissionGraphHandler(runner, jobworker.ExpandCopyMissionGraphDeps{ - Jobs: jobUseCase, - CopyMission: copyMissionUseCase, - Persona: personaUseCase, - KnowledgeGraph: knowledgeGraphUseCase, - ThreadsAccount: threadsAccountUseCase, - Placement: placementUseCase, - AI: sc.AI, - }) - jobworker.RegisterGenerateCopyMatrixHandler(runner, jobworker.GenerateCopyMatrixDeps{ - Jobs: jobUseCase, - CopyMission: copyMissionUseCase, - Persona: personaUseCase, - ScanPost: scanPostUseCase, - CopyDraft: copyDraftUseCase, - KnowledgeGraph: knowledgeGraphUseCase, - ThreadsAccount: threadsAccountUseCase, - AI: sc.AI, - }) - jobworker.RegisterGenerateCopyDraftHandler(runner, jobworker.GenerateCopyDraftDeps{ - Jobs: jobUseCase, - CopyMission: copyMissionUseCase, - Persona: personaUseCase, - ScanPost: scanPostUseCase, - CopyDraft: copyDraftUseCase, - ThreadsAccount: threadsAccountUseCase, - AI: sc.AI, - }) jobworker.RegisterGenerateOutreachDraftHandler(runner, jobworker.GenerateOutreachDraftDeps{ Jobs: jobUseCase, Brand: brandUseCase, @@ -522,6 +489,33 @@ func NewServiceContext(c config.Config) *ServiceContext { AI: sc.AI, OutreachDraft: outreachDraftUseCase, }) + jobworker.RegisterGenerateTopicMatrixHandler(runner, jobworker.GenerateTopicMatrixDeps{ + Jobs: jobUseCase, + Persona: personaUseCase, + CopyDraft: copyDraftUseCase, + ContentOps: contentOpsUseCase, + ThreadsAccount: threadsAccountUseCase, + Placement: placementUseCase, + AI: sc.AI, + }) + jobworker.RegisterGenerateFormulaDraftHandler(runner, jobworker.GenerateFormulaDraftDeps{ + Jobs: jobUseCase, + Persona: personaUseCase, + ContentFormula: contentFormulaUseCase, + CopyDraft: copyDraftUseCase, + ThreadsAccount: threadsAccountUseCase, + Placement: placementUseCase, + AI: sc.AI, + }) + jobworker.RegisterGenerateRewriteDraftHandler(runner, jobworker.GenerateRewriteDraftDeps{ + Jobs: jobUseCase, + Persona: personaUseCase, + ContentFormula: contentFormulaUseCase, + CopyDraft: copyDraftUseCase, + ThreadsAccount: threadsAccountUseCase, + Placement: placementUseCase, + AI: sc.AI, + }) jobworker.RegisterRefreshThreadsTokenHandler(runner, jobworker.RefreshThreadsTokenDeps{ Jobs: jobUseCase, ThreadsAccount: threadsAccountUseCase, @@ -531,14 +525,11 @@ func NewServiceContext(c config.Config) *ServiceContext { ThreadsAccount: threadsAccountUseCase, PublishAnalytics: publishAnalyticsUseCase, }) - jobworker.RegisterRefillPublishInventoryHandler(runner, jobworker.RefillPublishInventoryDeps{ - Jobs: jobUseCase, - PublishInventory: publishInventoryUseCase, - PublishQueue: publishQueueUseCase, - CopyMission: copyMissionUseCase, - CopyDraft: copyDraftUseCase, - }) go runner.Start(workerCtx) + } else if redisClient != nil { + inlineCtx, cancel := context.WithCancel(context.Background()) + sc.stopWorker = cancel + sc.startInlineCopyWorkers(inlineCtx, hostname) } if c.JobScheduler.Enabled && redisClient != nil { diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index 31b0cc5..4efaa08 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -282,6 +282,33 @@ type ContentMatrixRowData struct { Rationale string `json:"rationale"` } +type ContentPlanData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + TopicCandidateID string `json:"topic_candidate_id,omitempty"` + Topic string `json:"topic"` + Mission string `json:"mission"` + TargetAudience string `json:"target_audience,omitempty"` + Angle string `json:"angle,omitempty"` + OpeningType string `json:"opening_type,omitempty"` + BodyType string `json:"body_type,omitempty"` + Emotion string `json:"emotion,omitempty"` + EndingType string `json:"ending_type,omitempty"` + CtaType string `json:"cta_type,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + RequiresHumanReview bool `json:"requires_human_review,omitempty"` + Avoid []string `json:"avoid,omitempty"` + SelectedKnowledge []string `json:"selected_knowledge,omitempty"` + Status string `json:"status,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + +type ContentPlanPath struct { + ID string `path:"id" validate:"required"` + ContentPlanID string `path:"contentPlanId" validate:"required"` +} + type CopyAudienceSampleData struct { Username string `json:"username"` SamplePostId string `json:"sample_post_id,omitempty"` @@ -294,25 +321,35 @@ type CopyAudienceSampleData struct { } type CopyDraftData struct { - ID string `json:"id"` - 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"` - Angle string `json:"angle,omitempty"` - Hook string `json:"hook,omitempty"` - Rationale string `json:"rationale,omitempty"` - ReferenceNotes string `json:"reference_notes,omitempty"` - Sources []string `json:"sources,omitempty"` - Status string `json:"status,omitempty"` - PublishQueueID string `json:"publish_queue_id,omitempty"` - PublishedMediaID string `json:"published_media_id,omitempty"` - PublishedPermalink string `json:"published_permalink,omitempty"` - PublishedAt int64 `json:"published_at,omitempty"` - CreateAt int64 `json:"create_at"` + ID string `json:"id"` + PersonaID string `json:"persona_id"` + ContentPlanID string `json:"content_plan_id,omitempty"` + 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"` + TopicTag string `json:"topic_tag,omitempty"` + Angle string `json:"angle,omitempty"` + Hook string `json:"hook,omitempty"` + Rationale string `json:"rationale,omitempty"` + ReferenceNotes string `json:"reference_notes,omitempty"` + Sources []string `json:"sources,omitempty"` + AiScore int `json:"ai_score,omitempty"` + FormulaScore int `json:"formula_score,omitempty"` + BrandFitScore int `json:"brand_fit_score,omitempty"` + RiskScore int `json:"risk_score,omitempty"` + SimilarityScore int `json:"similarity_score,omitempty"` + EngagementPotential int `json:"engagement_potential,omitempty"` + FreshnessScore int `json:"freshness_score,omitempty"` + ReviewSuggestion string `json:"review_suggestion,omitempty"` + Status string `json:"status,omitempty"` + PublishQueueID string `json:"publish_queue_id,omitempty"` + PublishedMediaID string `json:"published_media_id,omitempty"` + PublishedPermalink string `json:"published_permalink,omitempty"` + PublishedAt int64 `json:"published_at,omitempty"` + CreateAt int64 `json:"create_at"` } type CopyDraftPath struct { @@ -335,14 +372,25 @@ type CopyMissionData struct { } type CopyMissionInspirationData struct { - Label string `json:"label"` - SeedQuery string `json:"seed_query"` - Brief string `json:"brief"` - TrendReason string `json:"trend_reason,omitempty"` - TrendKeywords []string `json:"trend_keywords,omitempty"` - Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"` - WebSearchUsed bool `json:"web_search_used"` - Message string `json:"message"` + TopicCandidateID string `json:"topic_candidate_id,omitempty"` + ContentPlanID string `json:"content_plan_id,omitempty"` + Label string `json:"label"` + SeedQuery string `json:"seed_query"` + Brief string `json:"brief"` + TrendReason string `json:"trend_reason,omitempty"` + TrendKeywords []string `json:"trend_keywords,omitempty"` + Angles []string `json:"angles,omitempty"` + Mission string `json:"mission,omitempty"` + TargetAudience string `json:"target_audience,omitempty"` + OpeningType string `json:"opening_type,omitempty"` + BodyType string `json:"body_type,omitempty"` + Emotion string `json:"emotion,omitempty"` + CtaType string `json:"cta_type,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + Avoid []string `json:"avoid,omitempty"` + Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"` + WebSearchUsed bool `json:"web_search_used"` + Message string `json:"message"` } type CopyMissionInspirationHandlerReq struct { @@ -351,7 +399,10 @@ type CopyMissionInspirationHandlerReq struct { } type CopyMissionInspirationReq struct { - Keyword string `json:"keyword,optional"` + Keyword string `json:"keyword,optional"` + ContentDirection string `json:"content_direction,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + AvoidTopics []string `json:"avoid_topics,optional"` } type CopyMissionInspirationSourceData struct { @@ -399,6 +450,21 @@ type CopyMissionScanScheduleData struct { LastRunAt int64 `json:"last_run_at,omitempty"` } +type CopyMissionScheduleCopyDraftsData struct { + Scheduled int `json:"scheduled"` + List []PublishQueueItemData `json:"list"` + Message string `json:"message"` +} + +type CopyMissionScheduleCopyDraftsReq struct { + AccountID string `json:"account_id" validate:"required"` + DraftIDs []string `json:"draft_ids" validate:"required,min=1"` + StartAt int64 `json:"start_at,optional"` + Timezone string `json:"timezone,optional"` + Slots []PublishSlotData `json:"slots,optional"` + Mode string `json:"mode,optional"` +} + type CopyMissionSimilarAccountPath struct { PersonaID string `path:"personaId" validate:"required"` ID string `path:"id" validate:"required"` @@ -456,6 +522,28 @@ type CreateBrandReq struct { DisplayName string `json:"display_name,optional"` } +type CreateContentPlanHandlerReq struct { + PersonaPath + CreateContentPlanReq +} + +type CreateContentPlanReq struct { + TopicCandidateID string `json:"topic_candidate_id,optional"` + Topic string `json:"topic" validate:"required"` + Mission string `json:"mission" validate:"required"` + TargetAudience string `json:"target_audience,optional"` + Angle string `json:"angle,optional"` + OpeningType string `json:"opening_type,optional"` + BodyType string `json:"body_type,optional"` + Emotion string `json:"emotion,optional"` + EndingType string `json:"ending_type,optional"` + CtaType string `json:"cta_type,optional"` + RiskLevel string `json:"risk_level,optional"` + RequiresHumanReview bool `json:"requires_human_review,optional"` + Avoid []string `json:"avoid,optional"` + SelectedKnowledge []string `json:"selected_knowledge,optional"` +} + type CreateCopyMissionHandlerReq struct { PersonaCopyMissionsPath CreateCopyMissionReq @@ -467,6 +555,33 @@ type CreateCopyMissionReq struct { Brief string `json:"brief" validate:"required"` } +type CreateFeedbackEventHandlerReq struct { + PersonaPath + CreateFeedbackEventReq +} + +type CreateFeedbackEventReq struct { + ContentPlanID string `json:"content_plan_id,optional"` + DraftID string `json:"draft_id,optional"` + Decision string `json:"decision" validate:"required"` + Note string `json:"note,optional"` + Snapshot string `json:"snapshot,optional"` +} + +type CreateFormulaPoolHandlerReq struct { + PersonaPath + CreateFormulaPoolReq +} + +type CreateFormulaPoolReq struct { + Type string `json:"type" validate:"required"` + Name string `json:"name" validate:"required"` + Pattern string `json:"pattern,optional"` + UseCases []string `json:"use_cases,optional"` + Avoid []string `json:"avoid,optional"` + Weight int `json:"weight,optional"` +} + type CreateJobReq struct { TemplateType string `json:"template_type" validate:"required"` // job template type Scope string `json:"scope" validate:"required,oneof=user account system persona brand"` // job scope @@ -484,6 +599,19 @@ type CreateJobScheduleReq struct { Enabled bool `json:"enabled"` // enabled flag } +type CreateKnowledgeSourceHandlerReq struct { + PersonaPath + CreateKnowledgeSourceReq +} + +type CreateKnowledgeSourceReq struct { + SourceType string `json:"source_type" validate:"required"` + Title string `json:"title,optional"` + Filename string `json:"filename,optional"` + Content string `json:"content,optional"` + ContentBase64 string `json:"content_base64,optional"` +} + type CreatePersonaReq struct { DisplayName string `json:"display_name,optional"` } @@ -507,6 +635,7 @@ type CreatePublishQueueHandlerReq struct { type CreatePublishQueueReq struct { Text string `json:"text" validate:"required"` + TopicTag string `json:"topic_tag,optional"` ScheduledAt int64 `json:"scheduled_at,optional"` } @@ -585,6 +714,30 @@ type ExpandPlacementTopicGraphHandlerReq struct { ExpandKnowledgeGraphReq } +type FeedbackEventData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + ContentPlanID string `json:"content_plan_id,omitempty"` + DraftID string `json:"draft_id,omitempty"` + Decision string `json:"decision"` + Note string `json:"note,omitempty"` + Snapshot string `json:"snapshot,omitempty"` + CreateAt int64 `json:"create_at"` +} + +type FormulaPoolData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Type string `json:"type"` + Name string `json:"name"` + Pattern string `json:"pattern,omitempty"` + UseCases []string `json:"use_cases,omitempty"` + Avoid []string `json:"avoid,omitempty"` + Weight int `json:"weight,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + type GenerateContentMatrixHandlerReq struct { BrandPath GenerateContentMatrixReq @@ -707,6 +860,24 @@ type GeneratePersonaCopyDraftReq struct { ScanPostID string `json:"scan_post_id" validate:"required"` } +type GeneratePersonaTopicMatrixData struct { + List []CopyDraftData `json:"list"` + Message string `json:"message"` +} + +type GeneratePersonaTopicMatrixHandlerReq struct { + PersonaPath + GeneratePersonaTopicMatrixReq +} + +type GeneratePersonaTopicMatrixReq struct { + Topic string `json:"topic" validate:"required"` + ContentPlanID string `json:"content_plan_id,optional"` + Brief string `json:"brief,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` +} + type GeneratePlacementTopicContentMatrixHandlerReq struct { PlacementTopicPath GenerateContentMatrixReq @@ -886,6 +1057,17 @@ type JobTemplateStepData struct { Cancelable bool `json:"cancelable"` } +type KnowledgeChunkData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + SourceID string `json:"source_id"` + Content string `json:"content"` + Topics []string `json:"topics,omitempty"` + StyleTags []string `json:"style_tags,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + CreateAt int64 `json:"create_at"` +} + type KnowledgeGraphData struct { ID string `json:"id"` BrandID string `json:"brand_id"` @@ -934,6 +1116,17 @@ type KnowledgeGraphNodeUpdate struct { RecencyTags []string `json:"recency_tags,optional"` } +type KnowledgeSourceData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + SourceType string `json:"source_type"` + Title string `json:"title,omitempty"` + Filename string `json:"filename,omitempty"` + ParsedStatus string `json:"parsed_status,omitempty"` + ChunkCount int `json:"chunk_count,omitempty"` + CreateAt int64 `json:"create_at"` +} + type ListBrandProductsData struct { List []BrandProductData `json:"list"` } @@ -976,6 +1169,10 @@ type ListContentFormulasReq struct { Tag string `form:"tag,optional"` } +type ListContentPlansData struct { + List []ContentPlanData `json:"list"` +} + type ListCopyMissionCopyDraftsData struct { List []CopyDraftData `json:"list"` Total int `json:"total"` @@ -994,6 +1191,10 @@ type ListCopyMissionsData struct { List []CopyMissionData `json:"list"` } +type ListFormulaPoolsData struct { + List []FormulaPoolData `json:"list"` +} + type ListJobEventsReq struct { ID string `path:"id" validate:"required"` // job run id Limit int64 `form:"limit,optional"` // max events @@ -1013,6 +1214,14 @@ type ListJobsReq struct { PageSize int64 `form:"pageSize,optional"` // page size } +type ListKnowledgeChunksData struct { + List []KnowledgeChunkData `json:"list"` +} + +type ListKnowledgeSourcesData struct { + List []KnowledgeSourceData `json:"list"` +} + type ListMentionInboxData struct { List []MentionInboxItemData `json:"list"` Pagination PaginationData `json:"pagination"` @@ -1135,6 +1344,10 @@ type ListThreadsPublishHealthQuery struct { PageSize int `form:"pageSize,optional"` } +type ListTopicCandidatesData struct { + List []TopicCandidateData `json:"list"` +} + type LogoutData struct { OK bool `json:"ok"` } @@ -1297,6 +1510,7 @@ type PatchPublishQueueHandlerReq struct { type PatchPublishQueueReq struct { Text *string `json:"text,optional"` + TopicTag *string `json:"topic_tag,optional"` ScheduledAt *int64 `json:"scheduled_at,optional"` } @@ -1379,6 +1593,21 @@ type PlacementTopicPath struct { ID string `path:"id" validate:"required"` } +type PrunePersonaCopyDraftsData struct { + Deleted int64 `json:"deleted"` + Kept int `json:"kept"` + Message string `json:"message"` +} + +type PrunePersonaCopyDraftsHandlerReq struct { + PersonaPath + PrunePersonaCopyDraftsReq +} + +type PrunePersonaCopyDraftsReq struct { + Keep int `json:"keep,optional"` +} + type PublishAlertData struct { Type string `json:"type"` Severity string `json:"severity"` @@ -1406,8 +1635,9 @@ type PublishCopyDraftHandlerReq struct { } type PublishCopyDraftReq struct { - Text string `json:"text,optional"` - Confirm bool `json:"confirm"` + Text string `json:"text,optional"` + TopicTag string `json:"topic_tag,optional"` + Confirm bool `json:"confirm"` } type PublishDashboardAccountSummary struct { @@ -1520,6 +1750,7 @@ type PublishQueueItemData struct { CopyMissionID string `json:"copy_mission_id,omitempty"` CopyDraftID string `json:"copy_draft_id,omitempty"` Text string `json:"text"` + TopicTag string `json:"topic_tag,omitempty"` ScheduledAt int64 `json:"scheduled_at"` Status string `json:"status"` MediaID string `json:"media_id,optional"` @@ -1688,7 +1919,7 @@ type ScheduleCopyDraftsReq struct { type ScheduleCopyMissionDraftsHandlerReq struct { CopyMissionPath - ScheduleCopyDraftsReq + CopyMissionScheduleCopyDraftsReq } type SchedulePersonaCopyDraftHandlerReq struct { @@ -1698,6 +1929,7 @@ type SchedulePersonaCopyDraftHandlerReq struct { type SchedulePersonaCopyDraftReq struct { AccountID string `json:"account_id" validate:"required"` + TopicTag string `json:"topic_tag,optional"` ScheduledAt int64 `json:"scheduled_at,optional"` } @@ -1821,12 +2053,74 @@ type StartCopyMissionScanJobData struct { Message string `json:"message"` } +type StartPersonaFormulaDraftJobData struct { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` +} + +type StartPersonaFormulaDraftJobHandlerReq struct { + PersonaPath + StartPersonaFormulaDraftJobReq +} + +type StartPersonaFormulaDraftJobReq struct { + AccountID string `json:"account_id" validate:"required"` + FormulaID string `json:"formula_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 StartPersonaRewriteDraftJobData struct { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` +} + +type StartPersonaRewriteDraftJobHandlerReq struct { + PersonaPath + StartPersonaRewriteDraftJobReq +} + +type StartPersonaRewriteDraftJobReq struct { + AccountID string `json:"account_id" validate:"required"` + ReferenceText string `json:"reference_text" validate:"required"` + Topic string `json:"topic" validate:"required"` + Brief string `json:"brief,optional"` + SaveLabel string `json:"save_label,optional"` + UseWebSearch bool `json:"use_web_search,optional"` + DraftCount int `json:"draft_count,optional"` +} + type StartPersonaStyleAnalysisData struct { JobID string `json:"job_id"` Status string `json:"status"` Message string `json:"message"` } +type StartPersonaStyleAnalysisFromTextData struct { + Persona PersonaData `json:"persona"` + PostCount int `json:"post_count"` + Message string `json:"message"` +} + +type StartPersonaStyleAnalysisFromTextHandlerReq struct { + PersonaPath + StartPersonaStyleAnalysisFromTextReq +} + +type StartPersonaStyleAnalysisFromTextReq struct { + ReferenceTexts []string `json:"reference_texts,optional"` + RawText string `json:"raw_text,optional"` + SourceLabel string `json:"source_label,optional"` +} + type StartPersonaStyleAnalysisHandlerReq struct { PersonaPath StartPersonaStyleAnalysisReq @@ -1836,6 +2130,19 @@ type StartPersonaStyleAnalysisReq struct { BenchmarkUsername string `json:"benchmark_username" validate:"required"` } +type StartPersonaTopicMatrixJobData struct { + JobID string `json:"job_id"` + Status string `json:"status"` + AiProvider string `json:"ai_provider"` + AiModel string `json:"ai_model"` + Message string `json:"message"` +} + +type StartPersonaTopicMatrixJobHandlerReq struct { + PersonaPath + GeneratePersonaTopicMatrixReq +} + type StartPersonaViralScanJobData struct { JobID string `json:"job_id"` Status string `json:"status"` @@ -2154,6 +2461,27 @@ type ThreadsPublishHealthSummary struct { TotalReplies7d int `json:"total_replies_7d"` } +type TopicCandidateData struct { + ID string `json:"id"` + PersonaID string `json:"persona_id"` + Name string `json:"name"` + Source string `json:"source,omitempty"` + Category string `json:"category,omitempty"` + SeedQuery string `json:"seed_query,omitempty"` + TrendReason string `json:"trend_reason,omitempty"` + TrendKeywords []string `json:"trend_keywords,omitempty"` + HeatScore int `json:"heat_score,omitempty"` + FitScore int `json:"fit_score,omitempty"` + InteractionScore int `json:"interaction_score,omitempty"` + ExtendScore int `json:"extend_score,omitempty"` + RiskScore int `json:"risk_score,omitempty"` + FinalScore float64 `json:"final_score,omitempty"` + RecommendedMissions []string `json:"recommended_missions,omitempty"` + Status string `json:"status,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + type UpdateBrandHandlerReq struct { BrandPath UpdateBrandReq @@ -2189,16 +2517,40 @@ type UpdateBrandReq struct { PatrolKeywords []string `json:"patrol_keywords,optional"` } +type UpdateContentPlanHandlerReq struct { + ContentPlanPath + UpdateContentPlanReq +} + +type UpdateContentPlanReq struct { + Topic *string `json:"topic,optional"` + Mission *string `json:"mission,optional"` + TargetAudience *string `json:"target_audience,optional"` + Angle *string `json:"angle,optional"` + OpeningType *string `json:"opening_type,optional"` + BodyType *string `json:"body_type,optional"` + Emotion *string `json:"emotion,optional"` + EndingType *string `json:"ending_type,optional"` + CtaType *string `json:"cta_type,optional"` + RiskLevel *string `json:"risk_level,optional"` + RequiresHumanReview *bool `json:"requires_human_review,optional"` + Avoid []string `json:"avoid,optional"` + SelectedKnowledge []string `json:"selected_knowledge,optional"` + Status *string `json:"status,optional"` +} + type UpdateCopyDraftHandlerReq struct { CopyDraftPath UpdateCopyDraftReq } type UpdateCopyDraftReq struct { - Text *string `json:"text,optional"` - Hook *string `json:"hook,optional"` - Angle *string `json:"angle,optional"` - Status *string `json:"status,optional"` + Text *string `json:"text,optional"` + TopicTag *string `json:"topic_tag,optional"` + Hook *string `json:"hook,optional"` + Angle *string `json:"angle,optional"` + Rationale *string `json:"rationale,optional"` + Status *string `json:"status,optional"` } type UpdateCopyMissionHandlerReq struct { diff --git a/backend/internal/worker/job/analyze_copy_mission.go b/backend/internal/worker/job/analyze_copy_mission.go deleted file mode 100644 index cf94d4b..0000000 --- a/backend/internal/worker/job/analyze_copy_mission.go +++ /dev/null @@ -1,390 +0,0 @@ -package job - -import ( - "context" - "fmt" - "strings" - - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/library/placement" - libviral "haixun-backend/internal/library/viral" - "haixun-backend/internal/library/websearch" - domai "haixun-backend/internal/model/ai/domain/usecase" - aiusecase "haixun-backend/internal/model/ai/usecase" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - jobdom "haixun-backend/internal/model/job/domain/usecase" - personadomain "haixun-backend/internal/model/persona/domain/usecase" - placementusecase "haixun-backend/internal/model/placement/usecase" - threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" -) - -type AnalyzeCopyMissionDeps struct { - Jobs jobdom.UseCase - CopyMission missiondomain.UseCase - Persona personadomain.UseCase - ThreadsAccount threadsaccountdomain.UseCase - Placement placementusecase.UseCase - AI aiusecase.UseCase -} - -func RegisterAnalyzeCopyMissionHandler(runner *Runner, deps AnalyzeCopyMissionDeps) { - if runner == nil { - return - } - runner.RegisterStepHandler("copy_mission_map", func(ctx context.Context, step StepContext) error { - return runAnalyzeCopyMission(ctx, step, deps) - }) -} - -func runAnalyzeCopyMission(ctx context.Context, step StepContext, deps AnalyzeCopyMissionDeps) error { - payload := step.Run.Payload - tenantID, ownerUID := runActorFromPayload(payload, step.Run) - personaID := stringField(payload, "persona_id") - missionID := stringField(payload, "copy_mission_id") - if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" { - return fmt.Errorf("analyze-copy-mission payload missing tenant_id, owner_uid, persona_id, or copy_mission_id") - } - - mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID) - if err != nil { - return err - } - persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) - if err != nil { - return err - } - - updateProgress := func(summary string, percentage int) { - _ = step.Heartbeat(ctx) - _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Phase: "copy_mission_map", - Summary: summary, - Percentage: percentage, - }) - } - - updateProgress("產生拷貝任務研究地圖…", 15) - - credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) - if err != nil { - return err - } - providerID, err := aiusecase.MapWorkerProvider(credential.Provider) - if err != nil { - return err - } - - systemPrompt := libviral.BuildMissionResearchMapSystemPrompt() - userPrompt := libviral.BuildMissionResearchMapUserPrompt(libviral.CopyResearchMapInput{ - Label: mission.Label, - SeedQuery: mission.SeedQuery, - Brief: mission.Brief, - Persona: persona.Persona, - StyleBenchmark: persona.StyleBenchmark, - PersonaAudienceSummary: persona.CopyResearchMap.AudienceSummary, - PersonaContentGoal: persona.CopyResearchMap.ContentGoal, - PersonaQuestions: append([]string(nil), persona.CopyResearchMap.Questions...), - PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...), - }) - // ResearchGenerateRequest raises max_tokens so the full research map JSON is - // not truncated mid-object (truncation is the main cause of parse failures). - result, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - }, - })) - if err != nil { - return err - } - - parsed, err := libviral.ParseMissionResearchMapOutput(result.Text) - if err != nil { - // Model sometimes replies with reasoning/prose and no JSON object. - // Retry once, explicitly demanding a raw JSON object. - jsonOnlyResult, retryErr := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - {Role: "assistant", Content: result.Text}, - {Role: "user", Content: libviral.MissionResearchMapJSONOnlyRetryPrompt()}, - }, - })) - if retryErr != nil { - return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + err.Error()) - } - parsed, err = libviral.ParseMissionResearchMapOutput(jsonOnlyResult.Text) - if err != nil { - return app.For(code.AI).SvcThirdParty("拷貝任務研究地圖 LLM 回傳無法解析:" + err.Error()) - } - } - - entityTags := make([]missionentity.SuggestedTag, 0, len(parsed.SuggestedTags)) - for _, tag := range parsed.SuggestedTags { - entityTags = append(entityTags, missionentity.SuggestedTag{ - Tag: tag.Tag, - Reason: tag.Reason, - SearchIntent: tag.SearchIntent, - SearchType: tag.SearchType, - }) - } - researchMap := missionentity.ResearchMap{ - AudienceSummary: parsed.AudienceSummary, - ContentGoal: parsed.ContentGoal, - Questions: parsed.Questions, - Pillars: parsed.Pillars, - Exclusions: parsed.Exclusions, - KnowledgeItems: baseKnowledgeItems(parsed), - BenchmarkNotes: parsed.BenchmarkNotes, - SimilarAccounts: similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts), - } - researchMap.SuggestedTags = entityTags - updateProgress("補充相似帳號與延伸知識…", 70) - enrichMissionResearchMap(ctx, deps, tenantID, ownerUID, mission, &researchMap) - if len(researchMap.SelectedKnowledgeItems) == 0 { - researchMap.SelectedKnowledgeItems = defaultSelectedKnowledgeItems(researchMap.KnowledgeItems) - } - selected := libviral.PickDefaultSelectedTags(parsed.SuggestedTags) - selected = ensureSeedQuerySelected(mission.SeedQuery, selected) - - mapped := missionentity.StatusMapped - updateProgress("儲存研究地圖與預設標籤…", 85) - _, err = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - PersonaID: personaID, - MissionID: missionID, - Patch: missiondomain.MissionPatch{ - ResearchMap: &researchMap, - SelectedTagsSet: true, - SelectedTags: selected, - Status: &mapped, - }, - }) - if err != nil { - return err - } - - handoff := map[string]any{ - "flow": "copy", - "persona_id": personaID, - "copy_mission_id": missionID, - "summary": fmt.Sprintf("研究地圖就緒,已預選 %d 個搜尋標籤", len(selected)), - "next_route": fmt.Sprintf("/matrix/missions/%s", missionID), - } - - _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Result: map[string]any{ - "tag_count": len(entityTags), - "account_count": 0, - "selected_count": len(selected), - "handoff": handoff, - }, - }) - return err -} - -func enrichMissionResearchMap( - ctx context.Context, - deps AnalyzeCopyMissionDeps, - tenantID, ownerUID string, - mission *missiondomain.MissionSummary, - researchMap *missionentity.ResearchMap, -) { - if mission == nil || researchMap == nil || deps.Placement == nil || deps.ThreadsAccount == nil { - return - } - settings, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID) - if err != nil || !placement.WebSearchAvailable(settings) { - return - } - memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, settings) - if err != nil || memberCtx.WebSearchAPIKey() == "" { - return - } - client := websearch.New(memberCtx.WebSearchConfig()) - if !client.Enabled() { - return - } - if len(researchMap.SimilarAccounts) < libviral.MaxSimilarAccounts { - webAccounts, err := libviral.DiscoverSimilarAccounts(ctx, client, libviral.DiscoverAccountsInput{ - SeedQuery: mission.SeedQuery, - Brief: mission.Brief, - Pillars: researchMap.Pillars, - }) - if err == nil && len(webAccounts) > 0 { - researchMap.SimilarAccounts = libviral.MergeSimilarAccounts( - researchMap.SimilarAccounts, - similarAccountEntitiesFromWeb(webAccounts), - ) - } - } - if notes := collectMissionKnowledgeNotes(ctx, client, memberCtx, mission, researchMap); len(notes) > 0 { - researchMap.KnowledgeItems = mergeStringLists(researchMap.KnowledgeItems, notes) - } -} - -func similarAccountEntitiesFromWeb(in []libviral.SimilarAccount) []missionentity.SimilarAccount { - out := make([]missionentity.SimilarAccount, 0, len(in)) - for _, item := range in { - out = append(out, missionentity.SimilarAccount{ - Username: item.Username, - Reason: item.Reason, - Source: item.Source, - MatchedSource: item.MatchedSource, - Confidence: item.Confidence, - Status: missionentity.SimilarAccountStatusRecommended, - ProfileURL: item.ProfileURL, - }) - } - return out -} - -func collectMissionKnowledgeNotes( - ctx context.Context, - client websearch.Client, - member placement.MemberContext, - mission *missiondomain.MissionSummary, - researchMap *missionentity.ResearchMap, -) []string { - seed := strings.TrimSpace(mission.SeedQuery) - if seed == "" { - return nil - } - queries := []string{seed + " 重點 懶人包"} - if len(researchMap.Questions) > 0 { - if q := strings.TrimSpace(researchMap.Questions[0]); q != "" && q != seed { - queries = append(queries, q) - } - } - seen := map[string]struct{}{} - lines := []string{} - for _, query := range queries { - if len(lines) >= 5 { - break - } - res, err := client.Search(ctx, websearch.SearchOptions{ - Query: query, - Limit: 4, - Mode: websearch.ModeKnowledgeExpand, - Country: member.BraveCountry, - SearchLang: member.BraveSearchLang, - UserLocation: member.ExaUserLocation, - }) - if err != nil || res.Status != "success" { - continue - } - for _, item := range res.Results { - if len(lines) >= 5 { - break - } - title := strings.TrimSpace(item.Title) - snippet := strings.TrimSpace(item.Snippet) - if title == "" && snippet == "" { - continue - } - key := strings.ToLower(title + "|" + snippet) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - line := title - if snippet != "" { - if len([]rune(snippet)) > 120 { - snippet = string([]rune(snippet)[:120]) - } - line += ":" + snippet - } - lines = append(lines, line) - } - } - if len(lines) == 0 { - return nil - } - return lines -} - -func baseKnowledgeItems(parsed libviral.MissionResearchMap) []string { - items := []string{} - for _, pillar := range parsed.Pillars { - if strings.TrimSpace(pillar) != "" { - items = append(items, "內容支柱:"+strings.TrimSpace(pillar)) - } - } - for _, question := range parsed.Questions { - if strings.TrimSpace(question) != "" { - items = append(items, "受眾問題:"+strings.TrimSpace(question)) - } - } - if strings.TrimSpace(parsed.BenchmarkNotes) != "" { - items = append(items, "研究筆記:"+strings.TrimSpace(parsed.BenchmarkNotes)) - } - return mergeStringLists(nil, items) -} - -func defaultSelectedKnowledgeItems(items []string) []string { - if len(items) == 0 { - return nil - } - if len(items) > 6 { - return append([]string(nil), items[:6]...) - } - return append([]string(nil), items...) -} - -func mergeStringLists(base []string, extra []string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, len(base)+len(extra)) - for _, item := range append(append([]string{}, base...), extra...) { - item = strings.TrimSpace(item) - if item == "" { - continue - } - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} - out = append(out, item) - } - return out -} - -func ensureSeedQuerySelected(seed string, selected []string) []string { - seed = strings.TrimSpace(seed) - if seed == "" { - return selected - } - for _, item := range selected { - if strings.EqualFold(strings.TrimSpace(item), seed) { - return selected - } - } - out := append([]string{seed}, selected...) - if len(out) > libviral.MaxScanTags { - out = out[:libviral.MaxScanTags] - } - return out -} - -func copyMissionIDFromPayload(payload map[string]any) string { - if id := stringField(payload, "copy_mission_id"); id != "" { - return id - } - return strings.TrimSpace(stringField(payload, "mission_id")) -} diff --git a/backend/internal/worker/job/audience_samples.go b/backend/internal/worker/job/audience_samples.go deleted file mode 100644 index 41d2507..0000000 --- a/backend/internal/worker/job/audience_samples.go +++ /dev/null @@ -1,26 +0,0 @@ -package job - -import ( - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" -) - -func audienceSamplesFromSummary(in []missiondomain.AudienceSampleSummary) []missionentity.AudienceSample { - if len(in) == 0 { - return nil - } - out := make([]missionentity.AudienceSample, 0, len(in)) - for _, item := range in { - out = append(out, missionentity.AudienceSample{ - Username: item.Username, - SamplePostID: item.SamplePostID, - SampleText: item.SampleText, - ReplyLikeCount: item.ReplyLikeCount, - Appearances: item.Appearances, - FirstSeenAt: item.FirstSeenAt, - LastSeenAt: item.LastSeenAt, - Status: item.Status, - }) - } - return out -} diff --git a/backend/internal/worker/job/expand_copy_mission_graph.go b/backend/internal/worker/job/expand_copy_mission_graph.go deleted file mode 100644 index 1da4840..0000000 --- a/backend/internal/worker/job/expand_copy_mission_graph.go +++ /dev/null @@ -1,387 +0,0 @@ -package job - -import ( - "context" - "fmt" - "strings" - - "haixun-backend/internal/library/clock" - libcopy "haixun-backend/internal/library/copymission" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - libkg "haixun-backend/internal/library/knowledge" - "haixun-backend/internal/library/placement" - libprompt "haixun-backend/internal/library/prompt" - "haixun-backend/internal/library/websearch" - domai "haixun-backend/internal/model/ai/domain/usecase" - aiusecase "haixun-backend/internal/model/ai/usecase" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - jobdom "haixun-backend/internal/model/job/domain/usecase" - kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" - personadomain "haixun-backend/internal/model/persona/domain/usecase" - placementusecase "haixun-backend/internal/model/placement/usecase" - threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" -) - -type ExpandCopyMissionGraphDeps struct { - Jobs jobdom.UseCase - CopyMission missiondomain.UseCase - Persona personadomain.UseCase - KnowledgeGraph kgusecase.UseCase - ThreadsAccount threadsaccountdomain.UseCase - Placement placementusecase.UseCase - AI aiusecase.UseCase -} - -func RegisterExpandCopyMissionGraphHandler(runner *Runner, deps ExpandCopyMissionGraphDeps) { - if runner == nil { - return - } - runner.RegisterStepHandler("expand_copy_knowledge", func(ctx context.Context, step StepContext) error { - return runExpandCopyMissionGraph(ctx, step, deps) - }) -} - -func runExpandCopyMissionGraph(ctx context.Context, step StepContext, deps ExpandCopyMissionGraphDeps) error { - payload := step.Run.Payload - tenantID, ownerUID := runActorFromPayload(payload, step.Run) - personaID := stringField(payload, "persona_id") - missionID := copyMissionIDFromPayload(payload) - seed := stringField(payload, "seed_query") - supplemental := boolField(payload, "supplemental") - if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" { - return fmt.Errorf("expand-copy-mission-graph payload missing tenant_id, owner_uid, persona_id, or copy_mission_id") - } - if seed == "" { - return fmt.Errorf("expand-copy-mission-graph payload missing seed_query") - } - - mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID) - if err != nil { - return err - } - if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { - return app.For(code.Persona).InputMissingRequired("請先產生研究地圖再擴展延伸知識") - } - persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) - if err != nil { - return err - } - - research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID) - if err != nil { - return err - } - expandStrategy := placement.EffectiveExpandStrategy(research) - if reqStrategy := strings.TrimSpace(stringField(payload, "expand_strategy")); reqStrategy != "" { - expandStrategy = libkg.ParseExpandStrategy(reqStrategy) - } - if supplemental && placement.WebSearchAvailable(research) { - expandStrategy = libkg.ExpandStrategyBrave - } - memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research) - if err != nil { - return err - } - webClient := websearch.New(memberCtx.WebSearchConfig()) - - credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) - if err != nil { - return err - } - providerID, err := aiusecase.MapWorkerProvider(credential.Provider) - if err != nil { - return err - } - - entityMap := summaryResearchMap(mission.ResearchMap) - missionCtx := libcopy.MissionContext{ - Label: mission.Label, - SeedQuery: mission.SeedQuery, - Brief: mission.Brief, - ResearchMap: entityMap, - } - personaCtx := libcopy.PersonaContext{ - DisplayName: persona.DisplayName, - Persona: persona.Persona, - } - - updateProgress := func(summary string, percentage int) { - _ = step.Heartbeat(ctx) - _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Phase: "expand_copy_knowledge", - Summary: summary, - Percentage: percentage, - }) - } - - var existing *kgusecase.GraphSummary - if supplemental { - existing, _ = deps.KnowledgeGraph.GetByCopyMission(ctx, tenantID, ownerUID, missionID) - } - - braveSources := []libkg.BraveSource{} - var systemPrompt string - var userPrompt string - - switch expandStrategy { - case libkg.ExpandStrategyLLM: - updateProgress("整理延伸知識…", 25) - systemPrompt, err = libprompt.KnowledgeGraphLLMSystem() - if err != nil { - return app.For(code.AI).SysInternal("knowledge graph llm prompt load failed") - } - kgVars := map[string]string{ - "seed": seed, - "product_brief_line": libkg.OptionalPromptLine("任務補充", mission.Brief), - "target_audience_line": libkg.OptionalPromptLine("目標受眾", mission.ResearchMap.AudienceSummary), - "persona_line": libkg.OptionalPromptLine("人設", persona.Persona), - "research_pillars_line": libkg.BulletPromptLine("內容支柱", mission.ResearchMap.Pillars), - "research_questions_line": libkg.BulletPromptLine("受眾提問", mission.ResearchMap.Questions), - } - userPrompt, err = libprompt.KnowledgeGraphLLMUser(kgVars) - if err != nil { - return app.For(code.AI).SysInternal("knowledge graph llm user prompt load failed") - } - default: - updateProgress("蒐集參考資料…", 10) - l1Labels := []string{} - if existing != nil { - l1Labels = libkg.L1LabelsFromNodes(existing.Nodes) - } - planIn := libcopy.PlanInput(missionCtx, seed, l1Labels, supplemental, expandStrategy) - queries := libkg.PlanQueries(planIn) - updateProgress(fmt.Sprintf("蒐集參考資料(%d 項查詢)…", len(queries)), 25) - braveSources, err = runWebKnowledgeExpand(ctx, webClient, memberCtx, queries, expandStrategy, func(i, total int) { - pct := 25 + ((i + 1) * 30 / max(total, 1)) - updateProgress(fmt.Sprintf("蒐集參考資料 %d/%d…", i+1, total), pct) - }, func() error { - cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID) - if cancelled { - return errJobCancelled - } - return ctx.Err() - }) - if err != nil { - return err - } - updateProgress("整理延伸知識…", 60) - systemPrompt, err = libprompt.KnowledgeGraphSystem() - if err != nil { - return app.For(code.AI).SysInternal("knowledge graph prompt load failed") - } - userPrompt, err = libkg.BuildUserPrompt(libcopy.SynthInput(missionCtx, personaCtx, braveSources)) - if err != nil { - return app.For(code.AI).SysInternal("knowledge graph user prompt load failed") - } - } - - genReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - }, - }) - result, err := deps.AI.GenerateText(ctx, genReq) - if err != nil { - return err - } - - synthIn := libkg.SynthInput{ - Seed: seed, - TargetAudience: mission.ResearchMap.AudienceSummary, - } - graph, err := libkg.ParseSynthOutput(result.Text, synthIn, braveSources) - if err != nil { - // Model occasionally replies with reasoning/prose and no JSON object. - // Retry once, explicitly demanding a raw JSON object. - jsonOnlyReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - {Role: "assistant", Content: result.Text}, - {Role: "user", Content: libkg.KnowledgeGraphJSONOnlyRetryPrompt()}, - }, - }) - retryResult, retryErr := deps.AI.GenerateText(ctx, jsonOnlyReq) - if retryErr != nil { - return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + err.Error()) - } - retryGraph, retryParseErr := libkg.ParseSynthOutput(retryResult.Text, synthIn, braveSources) - if retryParseErr != nil { - return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + retryParseErr.Error()) - } - graph = retryGraph - result = retryResult - } - if libkg.GraphTooThin(graph) { - retryReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - {Role: "assistant", Content: result.Text}, - {Role: "user", Content: libkg.KnowledgeGraphRetryUserPrompt()}, - }, - }) - if retryResult, retryErr := deps.AI.GenerateText(ctx, retryReq); retryErr == nil { - if retryGraph, parseErr := libkg.ParseSynthOutput(retryResult.Text, synthIn, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) { - graph = retryGraph - } - } - } - - if supplemental && existing != nil { - graph = mergeGraphs(existing, graph, braveSources) - } - if libkg.GraphNeedsBootstrap(graph) { - libkg.SupplementGraphFromResearchMap(&graph, seed, mission.ResearchMap.Pillars, mission.ResearchMap.Questions) - } - preserveSelection := map[string]bool{} - if existing != nil { - for _, node := range existing.Nodes { - preserveSelection[node.ID] = node.SelectedForScan - } - } - libcopy.ApplyDefaultNodeSelectionPreserving(graph.Nodes, preserveSelection) - libkg.DeriveSearchTagsFromGraph(&graph, libcopy.PatrolTagInput(missionCtx)) - - updateProgress("儲存延伸知識…", 90) - graph.BraveSources = braveSources - now := clock.NowUnixNano() - saved, err := deps.KnowledgeGraph.Upsert(ctx, kgusecase.UpsertRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - BrandID: personaID, - CopyMissionID: missionID, - Seed: graph.Seed, - Nodes: graph.Nodes, - Edges: graph.Edges, - BraveSources: graph.BraveSources, - ExpandStrategy: expandStrategy.String(), - PainTagCount: graph.PainTagCount, - GeneratedAt: now, - }) - if err != nil { - return err - } - if err := syncCopyMissionKnowledgeFromGraph(ctx, deps, tenantID, ownerUID, personaID, missionID, entityMap, saved.Nodes, braveSources); err != nil { - return err - } - - handoff := map[string]any{ - "flow": "copy", - "persona_id": personaID, - "copy_mission_id": missionID, - "summary": fmt.Sprintf("延伸知識 %d 節點,痛點候選 %d", len(saved.Nodes), saved.PainTagCount), - "next_route": fmt.Sprintf("/matrix/missions/%s", missionID), - } - _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Result: map[string]any{ - "graph_id": saved.ID, - "seed": saved.Seed, - "pain_tag_count": saved.PainTagCount, - "node_count": len(saved.Nodes), - "handoff": handoff, - }, - }) - return err -} - -func summaryResearchMap(summary missiondomain.ResearchMapSummary) missionentity.ResearchMap { - tags := make([]missionentity.SuggestedTag, 0, len(summary.SuggestedTags)) - for _, tag := range summary.SuggestedTags { - tags = append(tags, missionentity.SuggestedTag{ - Tag: tag.Tag, - Reason: tag.Reason, - SearchIntent: tag.SearchIntent, - SearchType: tag.SearchType, - }) - } - accounts := make([]missionentity.SimilarAccount, 0, len(summary.SimilarAccounts)) - for _, acc := range summary.SimilarAccounts { - accounts = append(accounts, missionentity.SimilarAccount{ - Username: acc.Username, - Reason: acc.Reason, - Source: acc.Source, - MatchedSource: append([]string(nil), acc.MatchedSource...), - Confidence: acc.Confidence, - Status: acc.Status, - TopicRelevance: acc.TopicRelevance, - LastSeenAt: acc.LastSeenAt, - ProfileURL: acc.ProfileURL, - AuthorVerified: acc.AuthorVerified, - FollowerCount: acc.FollowerCount, - EngagementScore: acc.EngagementScore, - LikeCount: acc.LikeCount, - ReplyCount: acc.ReplyCount, - PostCount: acc.PostCount, - }) - } - samples := make([]missionentity.AudienceSample, 0, len(summary.AudienceSamples)) - for _, sample := range summary.AudienceSamples { - samples = append(samples, missionentity.AudienceSample{ - Username: sample.Username, - SamplePostID: sample.SamplePostID, - SampleText: sample.SampleText, - ReplyLikeCount: sample.ReplyLikeCount, - Appearances: sample.Appearances, - FirstSeenAt: sample.FirstSeenAt, - LastSeenAt: sample.LastSeenAt, - Status: sample.Status, - }) - } - return missionentity.ResearchMap{ - AudienceSummary: summary.AudienceSummary, - ContentGoal: summary.ContentGoal, - Questions: append([]string(nil), summary.Questions...), - Pillars: append([]string(nil), summary.Pillars...), - Exclusions: append([]string(nil), summary.Exclusions...), - KnowledgeItems: append([]string(nil), summary.KnowledgeItems...), - SelectedKnowledgeItems: append([]string(nil), summary.SelectedKnowledgeItems...), - SuggestedTags: tags, - SimilarAccounts: accounts, - AudienceSamples: samples, - BenchmarkNotes: summary.BenchmarkNotes, - } -} - -func syncCopyMissionKnowledgeFromGraph( - ctx context.Context, - deps ExpandCopyMissionGraphDeps, - tenantID, ownerUID, personaID, missionID string, - prev missionentity.ResearchMap, - nodes []libkg.Node, - sources []libkg.BraveSource, -) error { - researchMap := libcopy.BuildResearchMapFromGraph(prev, nodes, sources) - _, err := deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - PersonaID: personaID, - MissionID: missionID, - Patch: missiondomain.MissionPatch{ - ResearchMap: &researchMap, - }, - }) - return err -} diff --git a/backend/internal/worker/job/generate_copy_draft.go b/backend/internal/worker/job/generate_copy_draft.go deleted file mode 100644 index 29c7ea3..0000000 --- a/backend/internal/worker/job/generate_copy_draft.go +++ /dev/null @@ -1,210 +0,0 @@ -package job - -import ( - "context" - "fmt" - "strings" - - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - "haixun-backend/internal/library/style8d" - libviral "haixun-backend/internal/library/viral" - domai "haixun-backend/internal/model/ai/domain/usecase" - aiusecase "haixun-backend/internal/model/ai/usecase" - copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - jobdom "haixun-backend/internal/model/job/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 GenerateCopyDraftDeps struct { - Jobs jobdom.UseCase - CopyMission missiondomain.UseCase - Persona personadomain.UseCase - ScanPost scanpostdomain.UseCase - CopyDraft copydraftdomain.UseCase - ThreadsAccount threadsaccountdomain.UseCase - AI aiusecase.UseCase -} - -func RegisterGenerateCopyDraftHandler(runner *Runner, deps GenerateCopyDraftDeps) { - if runner == nil { - return - } - runner.RegisterStepHandler("copy_draft_generate", func(ctx context.Context, step StepContext) error { - return runGenerateCopyDraft(ctx, step, deps) - }) -} - -func runGenerateCopyDraft(ctx context.Context, step StepContext, deps GenerateCopyDraftDeps) error { - payload := step.Run.Payload - tenantID, ownerUID := runActorFromPayload(payload, step.Run) - personaID := stringField(payload, "persona_id") - missionID := copyMissionIDFromPayload(payload) - scanPostID := strings.TrimSpace(stringField(payload, "scan_post_id")) - if tenantID == "" || ownerUID == "" || personaID == "" || scanPostID == "" { - return fmt.Errorf("generate-copy-draft payload missing tenant_id, owner_uid, persona_id, or scan_post_id") - } - - updateProgress := func(summary string, percentage int) { - _ = step.Heartbeat(ctx) - _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Phase: "copy_draft_generate", - Summary: summary, - Percentage: percentage, - }) - } - - updateProgress("讀取爆款原文…", 12) - - persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) - if err != nil { - return err - } - post, err := deps.ScanPost.GetForPersona(ctx, tenantID, ownerUID, personaID, scanPostID) - if err != nil { - return err - } - if missionID == "" { - missionID = strings.TrimSpace(post.CopyMissionID) - } - - topicLabel := strings.TrimSpace(post.SearchTag) - topicBrief := strings.TrimSpace(persona.Brief) - if missionID != "" { - if mission, missionErr := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID); missionErr == nil { - if label := strings.TrimSpace(mission.Label); label != "" { - topicLabel = label - } - if brief := strings.TrimSpace(mission.Brief); brief != "" { - topicBrief = brief - } - } - } - - if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { - return app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") - } - personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) - - credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) - if err != nil { - return err - } - providerID, err := aiusecase.MapWorkerProvider(credential.Provider) - if err != nil { - return err - } - - updateProgress("分析爆款結構…", 35) - - analysisText := "" - analyzeResult, analyzeErr := deps.AI.GenerateText(ctx, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: libviral.BuildAnalyzeViralSystemPrompt(), - Messages: []domai.Message{ - { - Role: "user", - Content: libviral.BuildAnalyzeViralUserPrompt(libviral.AnalyzeViralInput{ - PostText: post.Text, - AuthorName: post.Author, - LikeCount: post.LikeCount, - ReplyCount: post.ReplyCount, - SearchTag: post.SearchTag, - TopicLabel: topicLabel, - TopicBrief: topicBrief, - Persona: personaBlock, - }), - }, - }, - }) - if analyzeErr == nil { - if parsed, parseErr := libviral.ParseAnalyzeViralOutput(analyzeResult.Text); parseErr == nil { - analysisText = libviral.FormatAnalysisForReplicate(parsed) - } - } - - updateProgress("深仿寫產文中…", 65) - - result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: libviral.BuildSystemPrompt(), - Messages: []domai.Message{ - { - Role: "user", - Content: libviral.BuildUserPrompt(libviral.ReplicateInput{ - TopicLabel: topicLabel, - TopicBrief: topicBrief, - Persona: personaBlock, - StyleProfile: "", - OriginalText: post.Text, - AuthorName: post.Author, - StructureAnalysis: analysisText, - }), - }, - }, - }) - if err != nil { - return err - } - - parsed, err := libviral.ParseReplicateOutput(result.Text) - if err != nil { - return app.For(code.AI).SvcThirdParty("仿寫 LLM 回傳無法解析:" + err.Error()) - } - - updateProgress("儲存仿寫草稿…", 90) - - saved, err := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - PersonaID: personaID, - CopyMissionID: post.CopyMissionID, - ScanPostID: scanPostID, - DraftType: "replicate", - Text: parsed.Text, - Angle: parsed.Angle, - Hook: parsed.Hook, - Rationale: parsed.Rationale, - ReferenceNotes: parsed.StructureNotes, - Sources: []string{post.Permalink}, - }) - if err != nil { - return err - } - - nextRoute := "/matrix" - if missionID != "" { - nextRoute = fmt.Sprintf("/matrix/missions/%s#copy-output", missionID) - } - handoff := map[string]any{ - "flow": "copy", - "persona_id": personaID, - "copy_mission_id": missionID, - "scan_post_id": scanPostID, - "summary": "已產出深仿寫草稿", - "next_route": nextRoute, - } - - _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Result: map[string]any{ - "draft_id": saved.ID, - "handoff": handoff, - }, - }) - return err -} diff --git a/backend/internal/worker/job/generate_copy_matrix.go b/backend/internal/worker/job/generate_copy_matrix.go deleted file mode 100644 index 901e559..0000000 --- a/backend/internal/worker/job/generate_copy_matrix.go +++ /dev/null @@ -1,290 +0,0 @@ -package job - -import ( - "context" - "fmt" - "strings" - - libcopy "haixun-backend/internal/library/copymission" - app "haixun-backend/internal/library/errors" - "haixun-backend/internal/library/errors/code" - libkg "haixun-backend/internal/library/knowledge" - libmatrix "haixun-backend/internal/library/matrix" - libprompt "haixun-backend/internal/library/prompt" - "haixun-backend/internal/library/style8d" - libweb "haixun-backend/internal/library/webpage" - domai "haixun-backend/internal/model/ai/domain/usecase" - aiusecase "haixun-backend/internal/model/ai/usecase" - copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity" - copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - jobdom "haixun-backend/internal/model/job/domain/usecase" - kgusecase "haixun-backend/internal/model/knowledge_graph/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 GenerateCopyMatrixDeps struct { - Jobs jobdom.UseCase - CopyMission missiondomain.UseCase - Persona personadomain.UseCase - ScanPost scanpostdomain.UseCase - CopyDraft copydraftdomain.UseCase - KnowledgeGraph kgusecase.UseCase - ThreadsAccount threadsaccountdomain.UseCase - AI aiusecase.UseCase -} - -func RegisterGenerateCopyMatrixHandler(runner *Runner, deps GenerateCopyMatrixDeps) { - if runner == nil { - return - } - runner.RegisterStepHandler("copy_matrix_generate", func(ctx context.Context, step StepContext) error { - return runGenerateCopyMatrix(ctx, step, deps) - }) -} - -func runGenerateCopyMatrix(ctx context.Context, step StepContext, deps GenerateCopyMatrixDeps) error { - payload := step.Run.Payload - tenantID, ownerUID := runActorFromPayload(payload, step.Run) - personaID := stringField(payload, "persona_id") - missionID := copyMissionIDFromPayload(payload) - if tenantID == "" || ownerUID == "" || personaID == "" || missionID == "" { - return fmt.Errorf("generate-copy-matrix payload missing tenant_id, owner_uid, persona_id, or copy_mission_id") - } - - mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID) - if err != nil { - return err - } - if mission.Status != string(missionentity.StatusMapped) && - mission.Status != string(missionentity.StatusScanned) && - mission.Status != string(missionentity.StatusDrafted) { - return app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣") - } - if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { - return app.For(code.Persona).InputMissingRequired("請先完成研究地圖") - } - - persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) - if err != nil { - return err - } - if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { - return app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") - } - personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief) - - count := intField(payload, "count") - if count <= 0 { - count = 5 - } - if count > 12 { - count = 12 - } - - updateProgress := func(summary string, percentage int) { - _ = step.Heartbeat(ctx) - _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Phase: "copy_matrix_generate", - Summary: summary, - Percentage: percentage, - }) - } - - updateProgress("讀取爆款樣本…", 15) - - posts, err := deps.ScanPost.ListForPersona(ctx, scanpostdomain.PersonaListRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - PersonaID: personaID, - CopyMissionID: missionID, - Limit: 12, - }) - if err != nil { - return err - } - samples := matrixSamplesFromPosts(posts) - graphNodes, graphSources := loadCopyMissionGraph(ctx, deps, tenantID, ownerUID, missionID) - knowledgeItems := libcopy.MatrixKnowledgeItems( - mission.ResearchMap.SelectedKnowledgeItems, - mission.ResearchMap.KnowledgeItems, - graphNodes, - mission.SelectedTags, - ) - researchSources := libcopy.ResearchSourcesFromSummaries(mission.ResearchMap.ResearchItems) - refURLs := libcopy.MergeReferenceURLs( - libcopy.ReferenceURLsFromSelection(graphNodes, graphSources), - researchSources, - ) - researchItemsBlock := libcopy.FormatResearchItemsForMatrix(researchSources) - var referenceMarkdown string - if len(refURLs) > 0 { - updateProgress(fmt.Sprintf("抓取 %d 個參考網頁並轉 Markdown…", len(refURLs)), 32) - digests := libweb.FetchDigests(ctx, refURLs, libweb.FetchOptions{}) - referenceMarkdown = libmatrix.BuildReferenceMarkdown(digests) - } - researchBlock := libmatrix.FormatCopyResearchMapBlockWithReferences( - mission.ResearchMap.AudienceSummary, - mission.ResearchMap.ContentGoal, - mission.ResearchMap.Questions, - mission.ResearchMap.Pillars, - mission.ResearchMap.Exclusions, - knowledgeItems, - researchItemsBlock, - referenceMarkdown, - ) - userPrompt, err := libmatrix.BuildCopyUserPrompt(libmatrix.CopyGenerateInput{ - Count: count, - TopicLabel: mission.Label, - TopicBrief: mission.Brief, - ResearchMap: researchBlock, - SelectedTags: mission.SelectedTags, - ViralSamples: samples, - PersonaBlock: personaBlock, - }) - if err != nil { - return app.For(code.AI).SysInternal("matrix user prompt load failed") - } - systemPrompt, err := libprompt.MatrixCopySystem() - if err != nil { - return app.For(code.AI).SysInternal("matrix system prompt load failed") - } - - matrixSummary := "產出內容矩陣草稿…" - if len(knowledgeItems) > 0 { - matrixSummary = fmt.Sprintf("依 %d 項延伸知識產出矩陣…", len(knowledgeItems)) - } - updateProgress(matrixSummary, 45) - - credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID) - if err != nil { - return err - } - providerID, err := aiusecase.MapWorkerProvider(credential.Provider) - if err != nil { - return err - } - parsed, err := libmatrix.GenerateCopyOutput(ctx, deps.AI, domai.GenerateRequest{ - Provider: providerID, - Model: credential.Model, - Credential: domai.Credential{ - APIKey: credential.APIKey, - }, - System: systemPrompt, - Messages: []domai.Message{ - {Role: "user", Content: userPrompt}, - }, - }, count) - if err != nil { - return app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error()) - } - - updateProgress("儲存矩陣草稿…", 88) - - createReqs := make([]copydraftdomain.CreateRequest, 0, len(parsed.Rows)) - for _, row := range parsed.Rows { - createReqs = append(createReqs, copydraftdomain.CreateRequest{ - CopyMissionID: missionID, - DraftType: copydraftentity.DraftTypeMatrix, - SortOrder: row.SortOrder, - Text: row.Text, - Angle: row.Angle, - Hook: row.Hook, - Rationale: row.Rationale, - ReferenceNotes: row.ReferenceNotes, - Sources: row.SourcePermalinks, - }) - } - saved, err := deps.CopyDraft.ReplaceMissionMatrix(ctx, tenantID, ownerUID, personaID, missionID, createReqs) - if err != nil { - return err - } - - drafted := missionentity.StatusDrafted - _, _ = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - PersonaID: personaID, - MissionID: missionID, - Patch: missiondomain.MissionPatch{ - Status: &drafted, - }, - }) - - handoff := map[string]any{ - "flow": "copy", - "persona_id": personaID, - "copy_mission_id": missionID, - "summary": fmt.Sprintf("已產出 %d 篇矩陣草稿", len(saved)), - "next_route": fmt.Sprintf("/matrix/missions/%s#copy-output", missionID), - } - - _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{ - JobID: step.JobID, - WorkerID: step.WorkerID, - Result: map[string]any{ - "draft_count": len(saved), - "handoff": handoff, - }, - }) - return err -} - -func loadCopyMissionGraph( - ctx context.Context, - deps GenerateCopyMatrixDeps, - tenantID, ownerUID, missionID string, -) ([]libkg.Node, []libkg.BraveSource) { - if deps.KnowledgeGraph == nil { - return nil, nil - } - graph, err := deps.KnowledgeGraph.GetByCopyMission(ctx, tenantID, ownerUID, missionID) - if err != nil || graph == nil { - return nil, nil - } - return graph.Nodes, graph.BraveSources -} - -func matrixSamplesFromPosts(posts []scanpostdomain.ScanPostSummary) string { - samples := make([]libmatrix.ViralPostSample, 0, len(posts)) - for _, post := range posts { - replies := make([]libmatrix.ViralReplySample, 0, len(post.Replies)) - for _, reply := range post.Replies { - replies = append(replies, libmatrix.ViralReplySample{ - Author: reply.Author, - Text: reply.Text, - }) - } - samples = append(samples, libmatrix.ViralPostSample{ - Author: post.Author, - LikeCount: post.LikeCount, - SearchTag: post.SearchTag, - Text: post.Text, - Replies: replies, - }) - } - return libmatrix.FormatViralSamples(samples) -} - -func intField(payload map[string]any, key string) int { - if payload == nil { - return 0 - } - switch v := payload[key].(type) { - case int: - return v - case int32: - return int(v) - case int64: - return int(v) - case float64: - return int(v) - default: - return 0 - } -} diff --git a/backend/internal/worker/job/generate_formula_draft.go b/backend/internal/worker/job/generate_formula_draft.go new file mode 100644 index 0000000..742b2de --- /dev/null +++ b/backend/internal/worker/job/generate_formula_draft.go @@ -0,0 +1,81 @@ +package job + +import ( + "context" + "fmt" + + libpersonacopy "haixun-backend/internal/library/personacopy" + aiusecase "haixun-backend/internal/model/ai/usecase" + contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + jobdom "haixun-backend/internal/model/job/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type GenerateFormulaDraftDeps struct { + Jobs jobdom.UseCase + Persona personadomain.UseCase + ContentFormula contentformuladomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI aiusecase.UseCase +} + +func RegisterGenerateFormulaDraftHandler(runner *Runner, deps GenerateFormulaDraftDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("formula_draft_generate", func(ctx context.Context, step StepContext) error { + return runGenerateFormulaDraft(ctx, step, deps) + }) +} + +func runGenerateFormulaDraft(ctx context.Context, step StepContext, deps GenerateFormulaDraftDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + personaID := stringField(payload, "persona_id") + accountID := stringField(payload, "account_id") + formulaID := stringField(payload, "formula_id") + topic := stringField(payload, "topic") + if tenantID == "" || ownerUID == "" || personaID == "" || accountID == "" || formulaID == "" || topic == "" { + return fmt.Errorf("generate-formula-draft payload missing required fields") + } + + updateProgress := func(summary string, percentage int) { + _ = step.Heartbeat(ctx) + _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Phase: "formula_draft_generate", + Summary: summary, + Percentage: percentage, + }) + } + + count, err := libpersonacopy.RunFormulaDraft(ctx, libpersonacopy.FormulaDraftDeps{ + Persona: deps.Persona, + ContentFormula: deps.ContentFormula, + CopyDraft: deps.CopyDraft, + ThreadsAccount: deps.ThreadsAccount, + Placement: deps.Placement, + AI: deps.AI, + }, libpersonacopy.FormulaDraftInput{ + TenantID: tenantID, + OwnerUID: ownerUID, + PersonaID: personaID, + AccountID: accountID, + FormulaID: formulaID, + Topic: topic, + Brief: stringField(payload, "brief"), + UseWebSearch: boolField(payload, "use_web_search"), + DraftCount: intField(payload, "draft_count"), + }, updateProgress) + if err != nil { + return err + } + _ = count + return nil +} diff --git a/backend/internal/worker/job/generate_outreach_draft.go b/backend/internal/worker/job/generate_outreach_draft.go index 93db13a..64cee8c 100644 --- a/backend/internal/worker/job/generate_outreach_draft.go +++ b/backend/internal/worker/job/generate_outreach_draft.go @@ -94,4 +94,4 @@ func runGenerateOutreachDraft(ctx context.Context, step StepContext, deps Genera } updateProgress("草稿已寫入", 100) return nil -} \ No newline at end of file +} diff --git a/backend/internal/worker/job/generate_rewrite_draft.go b/backend/internal/worker/job/generate_rewrite_draft.go new file mode 100644 index 0000000..157cd67 --- /dev/null +++ b/backend/internal/worker/job/generate_rewrite_draft.go @@ -0,0 +1,82 @@ +package job + +import ( + "context" + "fmt" + + libpersonacopy "haixun-backend/internal/library/personacopy" + aiusecase "haixun-backend/internal/model/ai/usecase" + contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + jobdom "haixun-backend/internal/model/job/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type GenerateRewriteDraftDeps struct { + Jobs jobdom.UseCase + Persona personadomain.UseCase + ContentFormula contentformuladomain.UseCase + CopyDraft copydraftdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI aiusecase.UseCase +} + +func RegisterGenerateRewriteDraftHandler(runner *Runner, deps GenerateRewriteDraftDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("rewrite_draft_generate", func(ctx context.Context, step StepContext) error { + return runGenerateRewriteDraft(ctx, step, deps) + }) +} + +func runGenerateRewriteDraft(ctx context.Context, step StepContext, deps GenerateRewriteDraftDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + personaID := stringField(payload, "persona_id") + accountID := stringField(payload, "account_id") + referenceText := stringField(payload, "reference_text") + topic := stringField(payload, "topic") + if tenantID == "" || ownerUID == "" || personaID == "" || accountID == "" || referenceText == "" || topic == "" { + return fmt.Errorf("generate-rewrite-draft payload missing required fields") + } + + updateProgress := func(summary string, percentage int) { + _ = step.Heartbeat(ctx) + _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Phase: "rewrite_draft_generate", + Summary: summary, + Percentage: percentage, + }) + } + + count, err := libpersonacopy.RunRewriteDraft(ctx, libpersonacopy.RewriteDraftDeps{ + Persona: deps.Persona, + ContentFormula: deps.ContentFormula, + CopyDraft: deps.CopyDraft, + ThreadsAccount: deps.ThreadsAccount, + Placement: deps.Placement, + AI: deps.AI, + }, libpersonacopy.RewriteDraftInput{ + TenantID: tenantID, + OwnerUID: ownerUID, + PersonaID: personaID, + AccountID: accountID, + ReferenceText: referenceText, + Topic: topic, + Brief: stringField(payload, "brief"), + SaveLabel: stringField(payload, "save_label"), + UseWebSearch: boolField(payload, "use_web_search"), + DraftCount: intField(payload, "draft_count"), + }, updateProgress) + if err != nil { + return err + } + _ = count + return nil +} diff --git a/backend/internal/worker/job/generate_topic_matrix.go b/backend/internal/worker/job/generate_topic_matrix.go new file mode 100644 index 0000000..700b416 --- /dev/null +++ b/backend/internal/worker/job/generate_topic_matrix.go @@ -0,0 +1,78 @@ +package job + +import ( + "context" + "fmt" + + libpersonacopy "haixun-backend/internal/library/personacopy" + aiusecase "haixun-backend/internal/model/ai/usecase" + contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase" + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + jobdom "haixun-backend/internal/model/job/domain/usecase" + personadomain "haixun-backend/internal/model/persona/domain/usecase" + placementusecase "haixun-backend/internal/model/placement/usecase" + threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" +) + +type GenerateTopicMatrixDeps struct { + Jobs jobdom.UseCase + Persona personadomain.UseCase + CopyDraft copydraftdomain.UseCase + ContentOps contentopsdomain.UseCase + ThreadsAccount threadsaccountdomain.UseCase + Placement placementusecase.UseCase + AI aiusecase.UseCase +} + +func RegisterGenerateTopicMatrixHandler(runner *Runner, deps GenerateTopicMatrixDeps) { + if runner == nil { + return + } + runner.RegisterStepHandler("topic_matrix_generate", func(ctx context.Context, step StepContext) error { + return runGenerateTopicMatrix(ctx, step, deps) + }) +} + +func runGenerateTopicMatrix(ctx context.Context, step StepContext, deps GenerateTopicMatrixDeps) error { + payload := step.Run.Payload + tenantID, ownerUID := runActorFromPayload(payload, step.Run) + personaID := stringField(payload, "persona_id") + topic := stringField(payload, "topic") + if tenantID == "" || ownerUID == "" || personaID == "" || topic == "" { + return fmt.Errorf("generate-topic-matrix payload missing tenant_id, owner_uid, persona_id, or topic") + } + + updateProgress := func(summary string, percentage int) { + _ = step.Heartbeat(ctx) + _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{ + JobID: step.JobID, + WorkerID: step.WorkerID, + Phase: "topic_matrix_generate", + Summary: summary, + Percentage: percentage, + }) + } + + created, err := libpersonacopy.RunTopicMatrix(ctx, libpersonacopy.TopicMatrixDeps{ + Persona: deps.Persona, + CopyDraft: deps.CopyDraft, + ContentOps: deps.ContentOps, + ThreadsAccount: deps.ThreadsAccount, + Placement: deps.Placement, + AI: deps.AI, + }, libpersonacopy.TopicMatrixInput{ + TenantID: tenantID, + OwnerUID: ownerUID, + PersonaID: personaID, + ContentPlanID: stringField(payload, "content_plan_id"), + Topic: topic, + Brief: stringField(payload, "brief"), + UseWebSearch: boolField(payload, "use_web_search"), + DraftCount: intField(payload, "draft_count"), + }, updateProgress) + if err != nil { + return err + } + _ = created + return nil +} diff --git a/backend/internal/worker/job/payload_helpers.go b/backend/internal/worker/job/payload_helpers.go new file mode 100644 index 0000000..7524168 --- /dev/null +++ b/backend/internal/worker/job/payload_helpers.go @@ -0,0 +1,89 @@ +package job + +import ( + "fmt" + "strconv" + "strings" + + missionentity "haixun-backend/internal/model/copy_mission/domain/entity" + missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" +) + +func intField(payload map[string]any, key string) int { + if payload == nil { + return 0 + } + switch v := payload[key].(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case string: + n, _ := strconv.Atoi(strings.TrimSpace(v)) + return n + default: + return 0 + } +} + +func copyMissionIDFromPayload(payload map[string]any) string { + if id := stringField(payload, "copy_mission_id"); id != "" { + return id + } + return stringField(payload, "mission_id") +} + +func similarAccountsFromSummary(items []missiondomain.SimilarAccountSummary) []missionentity.SimilarAccount { + out := make([]missionentity.SimilarAccount, 0, len(items)) + for _, item := range items { + username := strings.TrimSpace(strings.TrimPrefix(item.Username, "@")) + if username == "" { + continue + } + out = append(out, missionentity.SimilarAccount{ + Username: username, + Reason: item.Reason, + Source: item.Source, + MatchedSource: append([]string(nil), item.MatchedSource...), + Confidence: item.Confidence, + Status: item.Status, + TopicRelevance: item.TopicRelevance, + LastSeenAt: item.LastSeenAt, + ProfileURL: item.ProfileURL, + AuthorVerified: item.AuthorVerified, + FollowerCount: item.FollowerCount, + EngagementScore: item.EngagementScore, + LikeCount: item.LikeCount, + ReplyCount: item.ReplyCount, + PostCount: item.PostCount, + }) + } + return out +} + +func audienceSamplesFromSummary(items []missiondomain.AudienceSampleSummary) []missionentity.AudienceSample { + out := make([]missionentity.AudienceSample, 0, len(items)) + for _, item := range items { + username := strings.TrimSpace(strings.TrimPrefix(item.Username, "@")) + if username == "" { + continue + } + out = append(out, missionentity.AudienceSample{ + Username: username, + SamplePostID: item.SamplePostID, + SampleText: item.SampleText, + ReplyLikeCount: item.ReplyLikeCount, + Appearances: item.Appearances, + FirstSeenAt: item.FirstSeenAt, + LastSeenAt: item.LastSeenAt, + Status: item.Status, + }) + } + return out +} + +func debugPayloadValue(value any) string { + return fmt.Sprintf("%v", value) +} diff --git a/backend/internal/worker/job/refill_publish_inventory.go b/backend/internal/worker/job/refill_publish_inventory.go deleted file mode 100644 index d80a16d..0000000 --- a/backend/internal/worker/job/refill_publish_inventory.go +++ /dev/null @@ -1,134 +0,0 @@ -package job - -import ( - "context" - "fmt" - "strings" - - "haixun-backend/internal/library/publishschedule" - copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" - jobdom "haixun-backend/internal/model/job/domain/usecase" - inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase" - pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase" -) - -type RefillPublishInventoryDeps struct { - Jobs jobdom.UseCase - PublishInventory inventorydomain.UseCase - PublishQueue pqdomain.UseCase - CopyMission missiondomain.UseCase - CopyDraft copydraftdomain.UseCase -} - -func RegisterRefillPublishInventoryHandler(runner *Runner, deps RefillPublishInventoryDeps) { - if runner == nil { - return - } - runner.RegisterStepHandler("refill_publish_inventory", func(ctx context.Context, step StepContext) error { - return runRefillPublishInventory(ctx, step, deps) - }) -} - -func runRefillPublishInventory(ctx context.Context, step StepContext, deps RefillPublishInventoryDeps) error { - payload := step.Run.Payload - tenantID, ownerUID := runActorFromPayload(payload, step.Run) - accountID := stringField(payload, "account_id") - if tenantID == "" || ownerUID == "" || accountID == "" { - return fmt.Errorf("refill publish inventory payload missing tenant_id, owner_uid, or account_id") - } - if deps.PublishInventory == nil || deps.PublishQueue == nil { - return fmt.Errorf("publish inventory dependencies are not configured") - } - policy, err := deps.PublishInventory.Get(ctx, tenantID, ownerUID, accountID) - if err != nil { - return err - } - if !policy.Enabled { - return nil - } - health, err := deps.PublishQueue.ListPublishHealth(ctx, tenantID, ownerUID, accountID, 1, 1) - if err != nil { - return err - } - need := policy.LowStockThreshold - int(health.Summary.PendingScheduled) - if requested := intField(payload, "count"); requested > 0 { - need = requested - } - if need <= 0 { - return nil - } - if need > 12 { - need = 12 - } - slots := make([]publishschedule.Slot, 0, len(policy.Slots)) - for _, slot := range policy.Slots { - slots = append(slots, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time}) - } - times := publishschedule.BuildSchedule(0, policy.Timezone, slots, need) - for i := 0; i < need; i++ { - if err := step.Heartbeat(ctx); err != nil { - return err - } - ref := pickSourceRef(policy.SourceRefs, i) - text, personaID, missionID := refillText(ctx, deps, tenantID, ownerUID, ref, i) - createReq := pqdomain.CreateRequest{ - TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, - PersonaID: personaID, CopyMissionID: missionID, Text: text, - } - if i < len(times) { - createReq.ScheduledAt = times[i] - } - if deps.CopyDraft != nil && personaID != "" { - draft, err := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{ - TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, - CopyMissionID: missionID, DraftType: "inventory-refill", SortOrder: i + 1, - Text: text, Rationale: "自動補庫存產生", - }) - if err == nil && draft != nil { - createReq.CopyDraftID = draft.ID - } - } - item, err := deps.PublishQueue.Create(ctx, createReq) - if err != nil { - return err - } - if deps.CopyDraft != nil && createReq.CopyDraftID != "" { - _, _ = deps.CopyDraft.MarkScheduled(ctx, copydraftdomain.MarkScheduledRequest{ - TenantID: tenantID, OwnerUID: ownerUID, PersonaID: personaID, DraftID: createReq.CopyDraftID, QueueID: item.ID, - }) - } - } - return nil -} - -func pickSourceRef(items []inventorydomain.SourceRef, idx int) inventorydomain.SourceRef { - if len(items) == 0 { - return inventorydomain.SourceRef{Type: "manual_seed", ManualSeed: "分享一個今天巡樓觀察到的重點"} - } - return items[idx%len(items)] -} - -func refillText(ctx context.Context, deps RefillPublishInventoryDeps, tenantID, ownerUID string, ref inventorydomain.SourceRef, idx int) (string, string, string) { - personaID := strings.TrimSpace(ref.PersonaID) - missionID := strings.TrimSpace(ref.CopyMissionID) - seed := strings.TrimSpace(ref.ManualSeed) - if missionID != "" && personaID != "" && deps.CopyMission != nil { - if mission, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID); err == nil { - seed = strings.TrimSpace(firstNonEmpty(mission.Brief, mission.Label, mission.SeedQuery)) - } - } - if seed == "" { - seed = "分享一個今天巡樓觀察到的重點" - } - return fmt.Sprintf("%s\n\n巡樓筆記 #%d", seed, idx+1), personaID, missionID -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} diff --git a/backend/internal/worker/job/scan_placement.go b/backend/internal/worker/job/scan_placement.go index 0bd8235..3500db5 100644 --- a/backend/internal/worker/job/scan_placement.go +++ b/backend/internal/worker/job/scan_placement.go @@ -315,8 +315,8 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD "solved_by_prod": solved, "replies": replyCount, }, - "discover_note": discoverNote, - "test_patrol": testPatrol, + "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/internal/worker/job/similar_accounts.go b/backend/internal/worker/job/similar_accounts.go deleted file mode 100644 index 19f9b7a..0000000 --- a/backend/internal/worker/job/similar_accounts.go +++ /dev/null @@ -1,42 +0,0 @@ -package job - -import ( - missionentity "haixun-backend/internal/model/copy_mission/domain/entity" - missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" -) - -// similarAccountsFromSummary converts the read-side usecase summary back into a -// write-side entity slice so that worker jobs can preserve previously -// discovered accounts (including MatchedSource provenance) across re-runs -// without losing information. Fields that the summary does not carry are left -// at their zero value. -func similarAccountsFromSummary(in []missiondomain.SimilarAccountSummary) []missionentity.SimilarAccount { - if len(in) == 0 { - return nil - } - out := make([]missionentity.SimilarAccount, 0, len(in)) - for _, item := range in { - matched := item.MatchedSource - if len(matched) == 0 && item.Source != "" { - matched = []string{item.Source} - } - out = append(out, missionentity.SimilarAccount{ - Username: item.Username, - Reason: item.Reason, - Source: item.Source, - MatchedSource: matched, - Confidence: item.Confidence, - Status: item.Status, - TopicRelevance: item.TopicRelevance, - LastSeenAt: item.LastSeenAt, - ProfileURL: item.ProfileURL, - AuthorVerified: item.AuthorVerified, - FollowerCount: item.FollowerCount, - EngagementScore: item.EngagementScore, - LikeCount: item.LikeCount, - ReplyCount: item.ReplyCount, - PostCount: item.PostCount, - }) - } - return out -} diff --git a/backend/web/src/api/haixun.ts b/backend/web/src/api/haixun.ts index 8368447..5956284 100644 --- a/backend/web/src/api/haixun.ts +++ b/backend/web/src/api/haixun.ts @@ -74,6 +74,7 @@ export type PublishQueueItem = { copy_mission_id?: string; copy_draft_id?: string; text: string; + topic_tag?: string; scheduled_at: number; status: string; media_id?: string; @@ -180,14 +181,24 @@ export type Persona = { export type CopyDraft = { id: string; persona_id: string; + content_plan_id?: string; copy_mission_id?: string; scan_post_id?: string; formula_id?: string; draft_type: string; text: string; + topic_tag?: string; angle?: string; hook?: string; rationale?: string; + ai_score?: number; + formula_score?: number; + brand_fit_score?: number; + risk_score?: number; + similarity_score?: number; + engagement_potential?: number; + freshness_score?: number; + review_suggestion?: string; status?: string; publish_queue_id?: string; published_at?: number; @@ -195,6 +206,86 @@ export type CopyDraft = { create_at?: number; }; +export type CopyMissionInspiration = { + topic_candidate_id?: string; + content_plan_id?: string; + label: string; + seed_query: string; + brief: string; + trend_reason?: string; + trend_keywords?: string[]; + angles?: string[]; + mission?: string; + target_audience?: string; + opening_type?: string; + body_type?: string; + emotion?: string; + cta_type?: string; + risk_level?: string; + avoid?: string[]; + sources?: Array<{ query?: string; title?: string; snippet?: string; url?: string }>; + web_search_used: boolean; + message: string; +}; + +export type TopicCandidate = { + id: string; + persona_id: string; + name: string; + source?: string; + category?: string; + seed_query?: string; + trend_reason?: string; + trend_keywords?: string[]; + heat_score?: number; + fit_score?: number; + interaction_score?: number; + extend_score?: number; + risk_score?: number; + final_score?: number; + recommended_missions?: string[]; + status?: string; + create_at: number; + update_at: number; +}; + +export type ContentPlan = { + id: string; + persona_id: string; + topic_candidate_id?: string; + topic: string; + mission: string; + target_audience?: string; + angle?: string; + opening_type?: string; + body_type?: string; + emotion?: string; + ending_type?: string; + cta_type?: string; + risk_level?: string; + requires_human_review?: boolean; + avoid?: string[]; + selected_knowledge?: string[]; + status?: string; + create_at: number; + update_at: number; +}; + +export type FeedbackEvent = { + id: string; + persona_id: string; + content_plan_id?: string; + draft_id?: string; + decision: string; + note?: string; + snapshot?: string; + create_at: number; +}; + +export type KnowledgeSource = { id: string; persona_id: string; source_type: string; title?: string; filename?: string; parsed_status?: string; chunk_count?: number; create_at: number }; +export type KnowledgeChunk = { id: string; persona_id: string; source_id: string; content: string; topics?: string[]; style_tags?: string[]; risk_level?: string; create_at: number }; +export type FormulaPool = { id: string; persona_id: string; type: string; name: string; pattern?: string; use_cases?: string[]; avoid?: string[]; weight?: number; create_at: number; update_at: number }; + export type ContentInboxItem = CopyDraft & { scheduled_at?: number; lifecycle: "draft" | "scheduled" | "published" | string; @@ -435,17 +526,6 @@ export type StylePreset = { update_at: number; }; -export type CopyMission = { - id: string; - persona_id: string; - label?: string; - seed_query?: string; - brief?: string; - status?: string; - selected_tags?: string[]; - research_map?: Record; -}; - // ==================== Brand (full contract per backend) ==================== export type ResearchItemData = { title?: string; @@ -674,17 +754,39 @@ export type ScanPost = { outreach_status?: string; }; +export type JobStepProgress = { + id: string; + status: string; + message?: string; + started_at?: number; + ended_at?: number; +}; + export type JobRun = { id: string; template_type: string; status: string; phase?: string; - progress?: { percentage?: number; summary?: string }; + worker_type?: string; + progress?: { percentage?: number; summary?: string; steps?: JobStepProgress[] }; error?: string; + attempt?: number; + max_attempts?: number; create_at: number; update_at: number; }; +export type JobEvent = { + id: string; + job_id: string; + type: string; + from?: string; + to?: string; + message: string; + metadata?: Record; + create_at: number; +}; + export type JobList = { pagination: Pagination; list: JobRun[]; @@ -765,9 +867,9 @@ export const api = { publishQueue: (accountId: string, status = "", page = 1) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue?page=${page}&pageSize=20${status ? `&status=${status}` : ""}`, { auth: true }), - createPublishQueue: (accountId: string, body: { text: string; scheduled_at?: number }) => + createPublishQueue: (accountId: string, body: { text: string; topic_tag?: string; scheduled_at?: number }) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue`, { method: "POST", body, auth: true }), - patchPublishQueue: (accountId: string, qid: string, body: { text?: string; scheduled_at?: number }) => + patchPublishQueue: (accountId: string, qid: string, body: { text?: string; topic_tag?: string; scheduled_at?: number }) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}`, { method: "PATCH", body, auth: true }), cancelPublishQueue: (accountId: string, qid: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/cancel`, { method: "POST", auth: true }), @@ -846,11 +948,23 @@ 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 }), + startStyleAnalysisFromText: ( + id: string, + body: { reference_texts?: string[]; raw_text?: string; source_label?: string } + ) => + apiRequest<{ persona: Persona; post_count: number; message: string }>( + `/api/v1/personas/${id}/style-analysis-from-text`, + { method: "POST", body, 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 }), + pruneCopyDrafts: (personaId: string, body?: { keep?: number }) => + apiRequest<{ deleted: number; kept: number; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/prune`, { method: "POST", body: body ?? {}, auth: true }), + deleteDraft: (personaId: string, draftId: string) => + apiRequest<{ draft_id: string; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/${draftId}`, { method: "DELETE", 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 }) => { @@ -863,7 +977,7 @@ export const api = { 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 }) => + scheduleCopyDraft: (personaId: string, draftId: string, body: { account_id: string; topic_tag?: 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 }), @@ -890,19 +1004,34 @@ export const api = { deleteStylePreset: (personaId: string, presetId: string) => apiRequest(`/api/v1/personas/${personaId}/style-presets/${presetId}`, { method: "DELETE", auth: true }), - copyMissions: (personaId: string) => apiRequest<{ list: CopyMission[] }>(`/api/v1/personas/${personaId}/copy-missions`, { auth: true }), - createCopyMission: (personaId: string, body: { label: string; seed_query: string; brief: string }) => - apiRequest(`/api/v1/personas/${personaId}/copy-missions`, { method: "POST", body, auth: true }), - inspireCopyMission: (personaId: string, keyword: string) => - apiRequest>(`/api/v1/personas/${personaId}/copy-mission-inspiration`, { method: "POST", body: { keyword }, auth: true }), - startCopyMissionAnalyze: (personaId: string, missionId: string) => - apiRequest<{ job_id: string }>(`/api/v1/personas/${personaId}/copy-missions/${missionId}/analyze-jobs`, { method: "POST", auth: true }), - startCopyMissionMatrix: (personaId: string, missionId: string, count: number) => - apiRequest<{ job_id: string }>(`/api/v1/personas/${personaId}/copy-missions/${missionId}/matrix-jobs`, { method: "POST", body: { count }, auth: true }), - missionDrafts: (personaId: string, missionId: string) => - apiRequest<{ list: CopyDraft[]; total: number }>(`/api/v1/personas/${personaId}/copy-missions/${missionId}/copy-drafts`, { auth: true }), - 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 }), + generateTopicMatrix: (personaId: string, body: { topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) => + apiRequest<{ list: CopyDraft[]; message: string }>(`/api/v1/personas/${personaId}/topic-matrix/generate`, { method: "POST", body, auth: true }), + startTopicMatrixJob: (personaId: string, body: { topic: string; content_plan_id?: string; brief?: string; use_web_search?: boolean; draft_count?: number }) => + apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/topic-matrix/jobs`, { method: "POST", body, auth: true }), + startFormulaDraftJob: (personaId: string, body: { account_id: string; formula_id: string; topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) => + apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/formula-draft/jobs`, { method: "POST", body, auth: true }), + startRewriteDraftJob: (personaId: string, body: { account_id: string; reference_text: string; topic: string; brief?: string; save_label?: string; use_web_search?: boolean; draft_count?: number }) => + apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/rewrite-draft/jobs`, { method: "POST", body, auth: true }), + inspireCopyMission: (personaId: string, body: { keyword?: string; content_direction?: string; use_web_search?: boolean; avoid_topics?: string[] }) => + apiRequest(`/api/v1/personas/${personaId}/copy-mission-inspiration`, { method: "POST", body, auth: true }), + topicCandidates: (personaId: string) => + apiRequest<{ list: TopicCandidate[] }>(`/api/v1/personas/${personaId}/topic-candidates`, { auth: true }), + contentPlans: (personaId: string) => + apiRequest<{ list: ContentPlan[] }>(`/api/v1/personas/${personaId}/content-plans`, { auth: true }), + updateContentPlan: (personaId: string, contentPlanId: string, body: Record) => + apiRequest(`/api/v1/personas/${personaId}/content-plans/${contentPlanId}`, { method: "PATCH", body, auth: true }), + createFeedbackEvent: (personaId: string, body: { content_plan_id?: string; draft_id?: string; decision: string; note?: string; snapshot?: string }) => + apiRequest(`/api/v1/personas/${personaId}/feedback-events`, { method: "POST", body, auth: true }), + createKnowledgeSource: (personaId: string, body: { source_type: string; title?: string; filename?: string; content?: string; content_base64?: string }) => + apiRequest(`/api/v1/personas/${personaId}/knowledge-sources`, { method: "POST", body, auth: true }), + knowledgeSources: (personaId: string) => + apiRequest<{ list: KnowledgeSource[] }>(`/api/v1/personas/${personaId}/knowledge-sources`, { auth: true }), + knowledgeChunks: (personaId: string) => + apiRequest<{ list: KnowledgeChunk[] }>(`/api/v1/personas/${personaId}/knowledge-chunks`, { auth: true }), + formulaPools: (personaId: string) => + apiRequest<{ list: FormulaPool[] }>(`/api/v1/personas/${personaId}/formula-pools`, { auth: true }), + createFormulaPool: (personaId: string, body: { type: string; name: string; pattern?: string; use_cases?: string[]; avoid?: string[]; weight?: number }) => + apiRequest(`/api/v1/personas/${personaId}/formula-pools`, { method: "POST", body, auth: true }), // Brand core brands: () => apiRequest("/api/v1/brands/", { auth: true }), @@ -1028,8 +1157,10 @@ export const api = { 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 }), + jobs: (page = 1, pageSize = 30) => apiRequest(`/api/v1/jobs?page=${page}&pageSize=${pageSize}`, { auth: true }), getJob: (id: string) => apiRequest(`/api/v1/jobs/${id}`, { auth: true }), + jobEvents: (jobId: string, limit = 40) => + apiRequest<{ list: JobEvent[] }>(`/api/v1/jobs/${jobId}/events?limit=${limit}`, { 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/JobMonitor.tsx b/backend/web/src/components/JobMonitor.tsx index 3f98eef..a5a7f1e 100644 --- a/backend/web/src/components/JobMonitor.tsx +++ b/backend/web/src/components/JobMonitor.tsx @@ -2,11 +2,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api, JobRun } from "../api/haixun"; import { Badge, Button } from "./ui"; import { formatNano } from "../lib/format"; +import { canCancelJob, canRetryJob } from "../lib/jobActions"; +import { jobTemplateLabel } from "../lib/jobLabels"; import { jobStatusBadgeClass, jobStatusLabel } from "../lib/jobStatus"; import { AcIcon } from "./AcIcon"; import type { JobCreatedDetail } from "../lib/jobEvents"; const ACTIVE_STATUSES = ["running", "queued", "pending", "waiting_worker", "cancel_requested"]; +const PANEL_STATUSES = [...ACTIVE_STATUSES, "failed"]; function nowNano() { return Date.now() * 1_000_000; @@ -15,18 +18,98 @@ function nowNano() { function optimisticJob(detail: JobCreatedDetail): JobRun { return { id: detail.jobId, - template_type: detail.label || detail.templateType || "任務", + template_type: detail.templateType || detail.label || "任務", status: detail.status || "queued", progress: { percentage: 0, summary: "剛建立,等待執行…" }, create_at: nowNano(), - update_at: nowNano(), + update_at: nowNano() }; } +function JobMonitorItem({ + job, + actionLoading, + onCancel, + onRetry, + showError +}: { + job: JobRun; + actionLoading: boolean; + onCancel: () => void; + onRetry: () => void; + showError: boolean; +}) { + return ( +
+
+ {jobTemplateLabel(job.template_type)} + {jobStatusLabel(job.status)} +
+ + {job.progress ? ( +
+ {job.progress.percentage ?? 0}% + {job.progress.summary ? ` · ${job.progress.summary}` : ""} +
+ ) : null} + + {showError && job.error?.trim() ? ( +
+ {job.error.trim()} +
+ ) : null} + +
+ {formatNano(job.update_at)} +
+ {canCancelJob(job) ? ( + + ) : null} + {canRetryJob(job) ? ( + + ) : null} +
+
+
+ ); +} + export function JobMonitor() { const [jobs, setJobs] = useState([]); const [open, setOpen] = useState(false); - const [loading, setLoading] = useState(false); + const [actionJobId, setActionJobId] = useState(""); const [hot, setHot] = useState(false); const pendingRef = useRef>(new Map()); @@ -41,10 +124,10 @@ export function JobMonitor() { const load = useCallback(async () => { try { - const data = await api.jobs(1); + const data = await api.jobs(1, 20); setJobs(mergeWithPending(data.list || [])); } catch { - // silent fail for background monitor + // 背景浮層靜默失敗,完整錯誤到任務中心看 } }, [mergeWithPending]); @@ -59,8 +142,11 @@ export function JobMonitor() { void load(); }, [load]); - const activeJobs = jobs.filter((j) => ACTIVE_STATUSES.includes(j.status)); + const panelJobs = jobs.filter((j) => PANEL_STATUSES.includes(j.status)); + const activeJobs = panelJobs.filter((j) => ACTIVE_STATUSES.includes(j.status)); + const failedJobs = panelJobs.filter((j) => j.status === "failed").slice(0, 3); const hasActive = activeJobs.length > 0; + const hasFailed = failedJobs.length > 0; useEffect(() => { const intervalMs = hasActive ? 1000 : 4500; @@ -97,13 +183,30 @@ export function JobMonitor() { }, [burstRefresh]); async function cancel(job: JobRun) { - setLoading(true); + setActionJobId(job.id); try { await api.cancelJob(job.id); pendingRef.current.delete(job.id); await load(); + window.dispatchEvent(new CustomEvent("haixun:jobs-refresh")); + } catch { + // 錯誤細節到任務中心看 } finally { - setLoading(false); + setActionJobId(""); + } + } + + async function retry(job: JobRun) { + setActionJobId(job.id); + try { + await api.retryJob(job.id); + pendingRef.current.delete(job.id); + await load(); + window.dispatchEvent(new CustomEvent("haixun:jobs-refresh")); + } catch { + // 錯誤細節到任務中心看 + } finally { + setActionJobId(""); } } @@ -113,15 +216,15 @@ export function JobMonitor() { <> {open && ( @@ -146,7 +251,7 @@ export function JobMonitor() { bottom: "calc(6.5rem + env(safe-area-inset-bottom, 0px))", right: "1rem", zIndex: 120, - width: "min(340px, calc(100vw - 2rem))", + width: "min(360px, calc(100vw - 2rem))", maxHeight: "58vh", background: "var(--hx-surface)", border: "1px solid var(--hx-line)", @@ -154,7 +259,7 @@ export function JobMonitor() { boxShadow: "var(--shadow-card)", display: "flex", flexDirection: "column", - overflow: "hidden", + overflow: "hidden" }} >
- 進行中的任務 {count > 0 ? `(${count})` : ""} + 任務狀態 {count > 0 ? `(${count} 進行中)` : ""}
- {activeJobs.length === 0 ? ( -

目前沒有進行中的任務。

- ) : ( - activeJobs.map((job) => ( -
-
- {job.template_type} - {jobStatusLabel(job.status)} -
+ {activeJobs.length === 0 && failedJobs.length === 0 ? ( +

目前沒有進行中或失敗的任務。

+ ) : null} - {job.progress && ( -
- {job.progress.percentage ?? 0}% {job.progress.summary ? `· ${job.progress.summary}` : ""} -
- )} + {activeJobs.map((job) => ( + void cancel(job)} + onRetry={() => void retry(job)} + showError={false} + /> + ))} -
- {formatNano(job.update_at)} - - {["running", "queued", "pending"].includes(job.status) && ( - - )} -
- -
- {job.id} -
-
- )) - )} + {failedJobs.length > 0 ? ( + <> +

最近失敗

+ {failedJobs.map((job) => ( + void cancel(job)} + onRetry={() => void retry(job)} + showError + /> + ))} + + ) : null}
@@ -238,7 +326,7 @@ export function JobMonitor() { }} style={{ width: "100%", fontSize: "0.75rem" }} > - 前往任務中心 + 前往任務中心看完整紀錄
diff --git a/backend/web/src/components/ui.tsx b/backend/web/src/components/ui.tsx index 9896424..40d1ba1 100644 --- a/backend/web/src/components/ui.tsx +++ b/backend/web/src/components/ui.tsx @@ -71,12 +71,13 @@ export function Button({ variant = "secondary", children, loading, + type = "button", ...props }: ButtonHTMLAttributes & { variant?: ButtonVariant; loading?: boolean }) { const cls = variant === "primary" ? "ac-btn-primary" : variant === "danger" ? "ac-btn-danger" : variant === "soft" ? "ac-btn-soft" : "ac-btn-secondary"; const disabled = loading || props.disabled; return ( - + {canCancelJob(job) ? ( + + ) : null} + {canRetryJob(job) ? ( + + ) : null} + + + {expanded ? : null} + + + ); +} + export function JobsPage() { const [jobs, setJobs] = useState([]); const [schedules, setSchedules] = useState([]); const [error, setError] = useState(); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState("all"); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [expandedId, setExpandedId] = useState(""); + const [eventsByJob, setEventsByJob] = useState>({}); + const [eventsLoadingId, setEventsLoadingId] = useState(""); + const [actionJobId, setActionJobId] = useState(""); - async function load() { + async function load(nextPage = page) { + setLoading(true); setError(undefined); try { - const [jobData, scheduleData] = await Promise.all([api.jobs(), api.jobSchedules()]); + const [jobData, scheduleData] = await Promise.all([api.jobs(nextPage, 30), api.jobSchedules()]); setJobs(jobData.list || []); + setPage(Number(jobData.pagination?.page || nextPage)); + setTotalPages(Number(jobData.pagination?.totalPages || 1)); setSchedules(scheduleData.list || []); } catch (err) { setError(err); + } finally { + setLoading(false); } } useEffect(() => { - void load(); - const timer = window.setInterval(() => void load(), 5000); + void load(page); + const timer = window.setInterval(() => void load(page), 5000); return () => window.clearInterval(timer); - }, []); + }, [page]); + + const filteredJobs = useMemo(() => jobs.filter((job) => matchesFilter(job, filter)), [jobs, filter]); + + const counts = useMemo(() => ({ + all: jobs.length, + active: jobs.filter((j) => ACTIVE_JOB_STATUSES.has(j.status)).length, + failed: jobs.filter((j) => j.status === "failed").length, + done: jobs.filter((j) => DONE_STATUSES.has(j.status)).length + }), [jobs]); + + async function toggleEvents(job: JobRun) { + if (expandedId === job.id) { + setExpandedId(""); + return; + } + setExpandedId(job.id); + if (eventsByJob[job.id]) return; + setEventsLoadingId(job.id); + try { + const data = await api.jobEvents(job.id, 50); + setEventsByJob((prev) => ({ ...prev, [job.id]: data.list || [] })); + } catch (err) { + setError(err); + } finally { + setEventsLoadingId(""); + } + } async function cancelJob(job: JobRun) { - await api.cancelJob(job.id); - await load(); + if (!window.confirm(`確定取消「${jobTemplateLabel(job.template_type)}」?`)) return; + setActionJobId(job.id); + try { + await api.cancelJob(job.id); + await load(page); + window.dispatchEvent(new CustomEvent("haixun:jobs-refresh")); + } catch (err) { + setError(err); + } finally { + setActionJobId(""); + } } async function retryJob(job: JobRun) { - await api.retryJob(job.id); - await load(); + setActionJobId(job.id); + try { + await api.retryJob(job.id); + await load(page); + window.dispatchEvent(new CustomEvent("haixun:jobs-refresh")); + } catch (err) { + setError(err); + } finally { + setActionJobId(""); + } } + const filterButtons: Array<{ id: JobFilter; label: string; count: number }> = [ + { id: "all", label: "全部", count: counts.all }, + { id: "active", label: "進行中", count: counts.active }, + { id: "failed", label: "失敗", count: counts.failed }, + { id: "done", label: "已完成", count: counts.done } + ]; + return (
- void load()}>重新載入} /> + void load(page)}>重新載入} + /> -
- - - - - - - - - - - - {jobs.map((job) => ( - - - - - - - - ))} - -
任務狀態進度更新操作
- {job.template_type} -
{job.id}
-
{jobStatusLabel(job.status)}{job.progress?.percentage ?? 0}% {job.progress?.summary ? `- ${job.progress.summary}` : ""}{formatNano(job.update_at)} -
- {["running", "queued", "pending"].includes(job.status) ? : null} - {["failed", "cancelled"].includes(job.status) ? : null} -
-
- {jobs.length === 0 ?

目前沒有任務。

: null} +
+
+ {filterButtons.map((item) => ( + + ))} +
+ + {filteredJobs.map((job) => ( + void toggleEvents(job)} + onCancel={() => void cancelJob(job)} + onRetry={() => void retryJob(job)} + actionLoading={actionJobId === job.id} + /> + ))} + + {filteredJobs.length === 0 && !loading ? ( +

這個篩選下目前沒有任務。可以去文案任務頁產生草稿,或到找 Ta 頁啟動海巡。

+ ) : null} + +
+ + {page} / {totalPages} + +
- +
- - + + - + {schedules.map((schedule) => ( - - - + + + - + ))} @@ -106,4 +340,4 @@ export function JobsPage() { ); -} +} \ No newline at end of file diff --git a/backend/web/src/pages/MissionsPage.tsx b/backend/web/src/pages/MissionsPage.tsx index 6258744..a90ca3a 100644 --- a/backend/web/src/pages/MissionsPage.tsx +++ b/backend/web/src/pages/MissionsPage.tsx @@ -1,89 +1,178 @@ -import { useEffect, useMemo, useState } from "react"; -import { api, CopyDraft, CopyMission, Persona, ThreadsAccount } from "../api/haixun"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { api, ContentInboxItem, CopyMissionInspiration, Persona, ThreadsAccount } from "../api/haixun"; import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, Select, Textarea } from "../components/ui"; -import { inputDateTimeToNano, shortText } from "../lib/format"; -import { notifyJobCreated } from "../lib/jobEvents"; -import { ContentInboxPanel } from "./missions/ContentInboxPanel"; -import { FormulaWorkshopPanel } from "./missions/FormulaWorkshopPanel"; -import { PublishOpsPanel } from "./missions/PublishOpsPanel"; +import { formatNano, inputDateTimeToNano, nanoToInputDateTime, shortText } from "../lib/format"; +import { notifyJobCreated, requestJobsRefresh } from "../lib/jobEvents"; -type MissionTab = "workflow" | "inbox" | "reference" | "ops"; -type WorkStep = "source" | "matrix" | "drafts" | "schedule"; +type Mode = "direct" | "inspire" | "rewrite" | "matrix"; +type ContentDirection = "auto" | "daily" | "expert" | "resonance" | "checklist" | "hot_take" | "interaction"; +type InspirationKnowledgeCard = { + id: string; + title: string; + detail: string; + source?: string; +}; +type ContentPlanDraft = { + id?: string; + topicCandidateId?: string; + topic: string; + mission: string; + targetAudience: string; + angle: string; + openingType: string; + bodyType: string; + emotion: string; + ctaType: string; + riskLevel: string; + avoidText: string; +}; -const workSteps: Array<{ id: WorkStep; title: string; desc: string }> = [ - { id: "source", title: "找靈感", desc: "輸入主題,整理這次想參考的貼文方向。" }, - { id: "matrix", title: "做矩陣", desc: "套用人設,請 AI 產出一組內容角度。" }, - { id: "drafts", title: "修草稿", desc: "挑出可用內容,保留要排程的草稿。" }, - { id: "schedule", title: "排程", desc: "選帳號與開始時間,放進發文庫存。" } -]; +const TERMINAL_JOB_STATUSES = new Set(["succeeded", "failed", "cancelled", "completed"]); -function missionTitle(mission?: CopyMission) { - return mission?.label || mission?.seed_query || "尚未選擇主題"; +function useCopyJobWatcher(onComplete: () => void) { + const pendingRef = useRef>(new Set()); + + const trackJob = useCallback((jobId: string) => { + if (!jobId) return; + pendingRef.current.add(jobId); + requestJobsRefresh(); + }, []); + + useEffect(() => { + const timer = window.setInterval(() => { + if (pendingRef.current.size === 0) return; + void (async () => { + for (const jobId of [...pendingRef.current]) { + try { + const job = await api.getJob(jobId); + if (TERMINAL_JOB_STATUSES.has(job.status)) { + pendingRef.current.delete(jobId); + onComplete(); + requestJobsRefresh(); + } + } catch { + // keep polling until the job can be fetched + } + } + })(); + }, 2000); + return () => window.clearInterval(timer); + }, [onComplete]); + + return { trackJob, hasPending: () => pendingRef.current.size > 0 }; } -function missionHasResearch(mission?: CopyMission) { - if (!mission?.research_map) return false; - return Object.keys(mission.research_map).length > 0; +function accountLabel(account: ThreadsAccount) { + return account.display_name || account.username || account.id; +} + +function personaLabel(persona: Persona) { + return persona.display_name || persona.persona || persona.id; +} + +function normalizeTopicTag(value: string) { + return value.trim().replace(/^#+/, "").trim(); +} + +const CONTENT_DIRECTIONS: Array<{ id: ContentDirection; title: string; desc: string }> = [ + { id: "auto", title: "AI 幫我選", desc: "看熱度與人設自動選方向" }, + { id: "daily", title: "日常陪伴", desc: "生活感、心情、慢慢說" }, + { id: "expert", title: "專業知識", desc: "觀念、名詞、流程整理" }, + { id: "resonance", title: "情緒共鳴", desc: "讓讀者覺得這就是我" }, + { id: "checklist", title: "工具清單", desc: "可收藏、可照著做" }, + { id: "hot_take", title: "熱門觀點", desc: "接社群討論講自己的看法" }, + { id: "interaction", title: "互動提問", desc: "適合留言與社群感" } +]; + +function directionText(direction: ContentDirection) { + const item = CONTENT_DIRECTIONS.find((option) => option.id === direction); + return item ? `${item.title}:${item.desc}` : direction; +} + +function ContentDirectionPicker({ value, onChange }: { value: ContentDirection; onChange: (value: ContentDirection) => void }) { + return ( +
+ {CONTENT_DIRECTIONS.map((item) => ( + + ))} +
+ ); +} + +function lifecycleBadge(lifecycle: string) { + if (lifecycle === "published") return 已發布; + if (lifecycle === "scheduled") return 已排程; + return 草稿; +} + +function reviewBadge(status?: string) { + if (status === "ready") return 已核准; + if (status === "rejected") return 已退回; + return 待審核; +} + +function appendReviewNote(rationale: string | undefined, decision: "approve" | "reject", note: string) { + const label = decision === "approve" ? "人工核准" : "人工退回"; + const cleanNote = note.trim() || (decision === "approve" ? "可進入後續排程。" : "需要重寫或調整。 "); + return [rationale?.trim(), `【${label}】${cleanNote}`].filter(Boolean).join("\n\n"); +} + +function QualityScoreRow({ item }: { item: ContentInboxItem }) { + const scores = [ + { label: "AI感", value: item.ai_score, badHigh: true }, + { label: "公式感", value: item.formula_score, badHigh: true }, + { label: "人設", value: item.brand_fit_score, badHigh: false }, + { label: "風險", value: item.risk_score, badHigh: true }, + { label: "相似", value: item.similarity_score, badHigh: true }, + { label: "互動", value: item.engagement_potential, badHigh: false }, + { label: "新鮮", value: item.freshness_score, badHigh: false } + ].filter((score) => typeof score.value === "number" && score.value > 0); + if (!scores.length) return null; + return ( +
+ {scores.map((score) => { + const value = Number(score.value || 0); + const danger = score.badHigh ? value >= 70 : value < 60; + const warning = score.badHigh ? value >= 45 && value < 70 : value >= 60 && value < 75; + return {score.label} {value}; + })} +
+ ); } export function MissionsPage() { - const [tab, setTab] = useState("workflow"); - const [step, setStep] = useState("source"); - const [personas, setPersonas] = useState([]); const [accounts, setAccounts] = useState([]); + const [personas, setPersonas] = useState([]); + const [accountId, setAccountId] = useState(""); const [personaId, setPersonaId] = useState(""); - const [missions, setMissions] = useState([]); - const [selected, setSelected] = useState(); - const [drafts, setDrafts] = useState([]); - const [selectedDraftIds, setSelectedDraftIds] = useState([]); - const [scheduleAccountId, setScheduleAccountId] = useState(""); - const [scheduleStartAt, setScheduleStartAt] = useState(""); - const [label, setLabel] = useState(""); - const [seed, setSeed] = useState(""); - const [brief, setBrief] = useState(""); - const [keyword, setKeyword] = useState(""); + const [activeMode, setActiveMode] = useState("inspire"); + const [draftRefreshKey, setDraftRefreshKey] = useState(0); const [message, setMessage] = useState(""); const [error, setError] = useState(); const [loading, setLoading] = useState(false); - const [inspiring, setInspiring] = useState(false); - const [analyzing, setAnalyzing] = useState(false); - const [matrixing, setMatrixing] = useState(false); - const [scheduling, setScheduling] = useState(false); - const [highlightDraftId, setHighlightDraftId] = useState(); - const selectedPersona = useMemo(() => personas.find((persona) => persona.id === personaId), [personaId, personas]); - const readyDrafts = useMemo(() => drafts.filter((draft) => !draft.publish_queue_id), [drafts]); - const scheduledDrafts = drafts.length - readyDrafts.length; + const selectedPersona = useMemo(() => personas.find((item) => item.id === personaId), [personas, personaId]); - async function load(nextPersonaId = personaId, nextMissionId?: string) { + async function loadBasics() { setLoading(true); setError(undefined); try { - const [personaData, accountData] = await Promise.all([api.personas(), api.threadsAccounts()]); - const pid = nextPersonaId || personaData.list?.[0]?.id || ""; - setPersonas(personaData.list || []); - setAccounts(accountData.list || []); - setScheduleAccountId((current) => current || accountData.active_account_id || accountData.list?.[0]?.id || ""); - setPersonaId(pid); - if (!pid) { - setMissions([]); - setSelected(undefined); - setDrafts([]); - return; - } - const missionData = await api.copyMissions(pid); - const list = missionData.list || []; - setMissions(list); - const mission = list.find((item) => item.id === (nextMissionId || selected?.id)) || list[0]; - setSelected(mission); - if (mission) { - const draftData = await api.missionDrafts(pid, mission.id); - setDrafts(draftData.list || []); - setSelectedDraftIds([]); - } else { - setDrafts([]); - setSelectedDraftIds([]); - } + const [accountData, personaData] = await Promise.all([api.threadsAccounts(), api.personas()]); + const nextAccounts = accountData.list || []; + const nextPersonas = personaData.list || []; + setAccounts(nextAccounts); + setPersonas(nextPersonas); + setAccountId((current) => current || accountData.active_account_id || nextAccounts[0]?.id || ""); + setPersonaId((current) => current || nextPersonas[0]?.id || ""); } catch (err) { setError(err); } finally { @@ -92,379 +181,1054 @@ export function MissionsPage() { } useEffect(() => { - void load(); + void loadBasics(); }, []); - async function createMission() { - if (!personaId || !label.trim() || !seed.trim() || !brief.trim()) return; - setError(undefined); - const created = await api.createCopyMission(personaId, { label: label.trim(), seed_query: seed.trim(), brief: brief.trim() }); - setLabel(""); - setSeed(""); - setBrief(""); - setMessage("主題已建立,下一步可以做參考分析。"); - setStep("matrix"); - await load(personaId, created.id); - } + const refreshDraftBoard = useCallback(() => { + setDraftRefreshKey((value) => value + 1); + }, []); - async function inspire() { - if (!personaId) return; - setInspiring(true); - setError(undefined); - try { - const data = await api.inspireCopyMission(personaId, keyword); - setLabel(String(data.label || "")); - setSeed(String(data.seed_query || "")); - setBrief(String(data.brief || "")); - setMessage(String(data.message || "已整理一組可用主題,確認後建立即可。")); - } catch (err) { - setError(err); - } finally { - setInspiring(false); - } - } - - async function startAnalyze() { - if (!personaId || !selected || analyzing) return; - setAnalyzing(true); - setError(undefined); - try { - const data = await api.startCopyMissionAnalyze(personaId, selected.id); - notifyJobCreated({ jobId: data.job_id, label: "參考貼文分析" }); - setMessage("已開始分析參考貼文,完成後回來重新載入即可看到分析結果。"); - } catch (err) { - setError(err); - } finally { - setAnalyzing(false); - } - } - - async function startMatrix() { - if (!personaId || !selected || matrixing) return; - setMatrixing(true); - setError(undefined); - try { - const data = await api.startCopyMissionMatrix(personaId, selected.id, 8); - notifyJobCreated({ jobId: data.job_id, label: "內容矩陣" }); - setMessage("內容矩陣正在產生。完成後重新載入,就能在草稿區挑選。 "); - setStep("drafts"); - } catch (err) { - setError(err); - } finally { - setMatrixing(false); - } - } - - async function scheduleDrafts() { - if (!personaId || !selected || !scheduleAccountId || selectedDraftIds.length === 0) return; - setScheduling(true); - setError(undefined); - try { - const data = await api.scheduleMissionDrafts(personaId, selected.id, { - account_id: scheduleAccountId, - draft_ids: selectedDraftIds, - start_at: inputDateTimeToNano(scheduleStartAt), - timezone: "Asia/Taipei", - slots: [ - { weekday: 1, time: "09:30" }, - { weekday: 3, time: "12:30" }, - { weekday: 5, time: "18:30" } - ], - mode: "recommended" - }); - setMessage(data.message || `已排程 ${data.scheduled} 篇草稿。`); - setSelectedDraftIds([]); - await load(personaId, selected.id); - } catch (err) { - setError(err); - } finally { - setScheduling(false); - } - } - - function selectMission(mission: CopyMission) { - setSelected(mission); - setStep("matrix"); - void load(personaId, mission.id); - } - - function toggleDraft(id: string, checked: boolean) { - setSelectedDraftIds((ids) => checked ? [...ids, id] : ids.filter((item) => item !== id)); - } - - function selectAllReadyDrafts() { - setSelectedDraftIds(readyDrafts.map((draft) => draft.id)); - } - - function renderWorkflow() { - return ( -
- -
-
- 簡化流程 -

找參考貼文,套人設,一次做出可排程草稿。

-

不用先懂任務、公式、queue。照順序走:先定主題,再生內容矩陣,最後挑草稿排程。

-
-
- 目前人設 - {selectedPersona?.display_name || selectedPersona?.persona || "尚未建立人設"} - {selectedPersona?.brief || selectedPersona?.style_profile || "先到人設頁建立語氣,這裡就能直接套用。"} -
-
-
- -
- {workSteps.map((item, index) => ( - - ))} -
- - - {message ?

{message}

: null} - -
-
- 主題 - {missions.length} - 可重複使用的內容方向 -
-
- 草稿 - {drafts.length} - {scheduledDrafts > 0 ? `${scheduledDrafts} 篇已排程` : "還沒排程"} -
-
- 已選 - {selectedDraftIds.length} - 準備排程的草稿 -
-
- - {step === "source" ? renderSourceStep() : null} - {step === "matrix" ? renderMatrixStep() : null} - {step === "drafts" ? renderDraftStep() : null} - {step === "schedule" ? renderScheduleStep() : null} -
- ); - } - - function renderSourceStep() { - return ( -
- -
- - - - -
- setKeyword(e.target.value)} placeholder="輸入一個想追的主題" /> - -
-
- - setLabel(e.target.value)} placeholder="例如:把發文流程自動化" /> - - - setSeed(e.target.value)} placeholder="用一句話描述要參考的貼文類型" /> - - -
模板Cron任務類型排程規則 時區 狀態下一次下次執行
{schedule.template_type}{schedule.cron}{schedule.timezone}{scheduleTemplateLabel(schedule.template_type)}{schedule.cron}{schedule.timezone || "UTC"} {schedule.enabled ? "啟用" : "停用"}{formatNano(schedule.next_run_at)}{schedule.next_run_at ? formatNano(schedule.next_run_at) : "—"}