diff --git a/Makefile b/Makefile index 249a020..c81678a 100644 --- a/Makefile +++ b/Makefile @@ -214,6 +214,10 @@ install: ## [prod] 安裝 binary/前端/設定/systemd/nginx(需 root,在目 tidy: ## go mod tidy cd $(BACKEND_DIR) && go mod tidy +.PHONY: gen-api +gen-api: ## 用 goctl 從 .api 重新產生 handler/logic/types/routes + cd $(BACKEND_DIR) && goctl api go -api generate/api/gateway.api -dir . -home generate/goctl -style go_zero + .PHONY: fmt fmt: ## gofmt 後端 cd $(BACKEND_DIR) && gofmt -w . diff --git a/backend/docs/similar-accounts-ta-plan.md b/backend/docs/similar-accounts-ta-plan.md new file mode 100644 index 0000000..bb51acf --- /dev/null +++ b/backend/docs/similar-accounts-ta-plan.md @@ -0,0 +1,307 @@ +# 相似帳號 / 對標帳號 / 找 TA — 改進計畫 + +## ⚠ 換手進度(2026-06-28) + +### ✅ Phase 1 已完成(全部測試通過) +`make fmt && make test` 全 ok;`make build-frontend` 通過。`go build ./...` 通過。 + +已完成項目: +- **1.1 名額放寬**:`MaxSimilarAccounts` 5 → 10(`backend/internal/library/viral/discover_accounts.go:15`)。 +- **1.2 停止清空**:`analyze_copy_mission.go` 不再 `SimilarAccounts = nil`,改用 `similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts)` 保留前次(`backend/internal/worker/job/analyze_copy_mission.go:128`)。 +- **1.3 合併取代覆蓋**:`scan_viral.go` 改 `libviral.MergeSimilarAccounts(prev, new)`(`backend/internal/worker/job/scan_viral.go:195`)。 +- **1.4 排序權重抽出**:`reference_accounts.go` 新增 `ReferenceRankWeights` + `DefaultReferenceRankWeights` + `rankScore`(log-scale follower bucket 1k/10k/100k/1M、topicHits 加權),排序用加權和;`postTopicRelevant` 改為 `topicTopicHits` 回傳 hits 並用 `normalisedTopicTerms` lower-case 取代 `topicTerms`。 +- **1.5 web search fallback**:`scan_viral.go:170` `BuildReferenceAccountsFromScan` 後若 `len(referenceAccounts) < MaxSimilarAccounts` 且 `placement.WebSearchAvailable(research)` → 呼 `supplementSimilarAccountsFromWeb`(`scan_viral.go:369`),透過 `websearch.New(memberCtx.WebSearchConfig())` 建 client → `DiscoverSimilarAccounts` → `MergeSimilarAccounts` 串接。失敗安全:web search 失敗不讓 job fail。 +- **1.6 ProfileURL helper**:新檔 `backend/internal/library/threadsapi/url.go` `ProfileURLFromPermalink(permalink, username)`,優先用 permalink 反推、fallback `https://www.threads.com/@`(`.com` 為 canonical host)。`reference_accounts.go:137`、`enrich_accounts.go`、`discover_accounts.go` 已改用。 +- **1.7 Brave StartPublishedDate**:`brave.SearchOptions` 加 `StartPublishedDate string` 欄位(forward-compat,目前 brave client 尚未傳給 API);`websearch/client.go` braveAdapter 把 `opts.StartPublishedDate` 塞進 libbrave.SearchOptions。Exa 原本就有支援。 +- **1.8 前端顯示**:`frontend/src/types/copyMission.ts` `CopySimilarAccountData` 加 `matched_source?: string[]` + `similarAccountSourceTier`/`similarAccountSourceLabel` helper;`frontend/src/lib/viralSignals.ts` 加 `similarAccountTone`;`CopyMissionDetailPage.tsx:837-878` row 加「來源」badge(scan+web/sky/success、web/sky、scan/brand)、「置信度」小字。 +- **schema**:`missionentity.SimilarAccount` 加 `MatchedSource []string`(`backend/internal/model/copy_mission/domain/entity/mission.go:27`);`domusecase.SimilarAccountSummary` 同步;`usecase.toSummary` 帶 matched;`types.CopySimilarAccountData` 同步;`logic/copy_mission/mapper.go` entity↔API 兩處對映帶 matched;`generate/api/copy_mission.api` 的 `CopySimilarAccountData` 同步(手動補,**未跑 `make gen-api`**——必要時下一手可跑 gen-api 重新產生以對齊)。 +- 新檔: + - `backend/internal/library/viral/merge_accounts.go` `MergeSimilarAccounts` + `unionMatchedSource`。 + - `backend/internal/library/threadsapi/url.go` `ProfileURLFromPermalink`。 + - `backend/internal/worker/job/similar_accounts.go` `similarAccountsFromSummary`(summary→entity 轉換 helper,給 worker 跨 rerun 保留 Previous)。 + +### 未做、給下一手 +- **Phase 2**:相似帳號變一等公民(`Status`/`TopicRelevance`/`LastSeenAt` schema + PATCH API + worker 尊重 excluded/pinned + 前端釘選/排除按鈕 + Placement 維持不動)。詳見下方 Phase 2 章節。 +- **Phase 3**:TA 樣本(留言作者採樣,`AudienceSample` + `audience.go` + worker 接入 + PATCH API + 前端「TA 樣本」區)。詳見下方 Phase 3 章節。 +- **Phase 4**:跨任務記憶(後續,未規劃細節)。 +- **重新 gen-api**:`generate/api/copy_mission.api` 只有 `CopySimilarAccountData` 加 `matched_source` 一處,types.go 已手動同步。若下一手要擴 API schema(Phase 2),建議跑 `make gen-api` 重新產 handler/logic/types 確保對齊。 +- **新測試**:Phase 1 沒額外加新 test case。建議下一手補:`viral/merge_accounts_test.go`(`MergeSimilarAccounts` merges、preserves prev、truncates to limit、unionMatchedSource dedup)、`viral/reference_accounts_test.go` 新排序 case(topicHits 加權、log-scale follower bucket)、`worker/job/scan_viral_test.go` web fallback case(mock websearch client)。這些 test 是 Phase 1 的「補強」層,不阻塞 Phase 2 進行。 + +### 已動的檔案清單(Phase 1) +**Go 後端** +- `backend/internal/library/viral/discover_accounts.go`(常數、ProfileURL helper、accountCandidate.permalink) +- `backend/internal/library/viral/reference_accounts.go`(ReferenceRankWeights、topicHits、MatchedSource、ProfileURL helper、刪 topicTerms) +- `backend/internal/library/viral/enrich_accounts.go`(MatchedSource、ProfileURL helper、authorScore.permalink) +- `backend/internal/library/viral/merge_accounts.go`(**新檔**) +- `backend/internal/library/threadsapi/url.go`(**新檔**) +- `backend/internal/library/websearch/client.go`(braveAdapter 傳 StartPublishedDate) +- `backend/internal/library/brave/client.go`(SearchOptions 加 StartPublishedDate) +- `backend/internal/worker/job/scan_viral.go`(web fallback + MergeSimilarAccounts + supplementSimilarAccountsFromWeb) +- `backend/internal/worker/job/analyze_copy_mission.go`(停止清空 SimilarAccounts) +- `backend/internal/worker/job/similar_accounts.go`(**新檔**:similarAccountsFromSummary) +- `backend/internal/model/copy_mission/domain/entity/mission.go`(SimilarAccount 加 MatchedSource) +- `backend/internal/model/copy_mission/domain/usecase/usecase.go`(SimilarAccountSummary 加 MatchedSource) +- `backend/internal/model/copy_mission/usecase/usecase.go`(toSummary 帶 matched) +- `backend/internal/types/types.go`(CopySimilarAccountData 加 matched_source) +- `backend/generate/api/copy_mission.api`(CopySimilarAccountData 加 matched_source) +- `backend/internal/logic/copy_mission/mapper.go`(兩處對映帶 matched) + +**前端** +- `frontend/src/types/copyMission.ts`(CopySimilarAccountData 加 matched_source + helper) +- `frontend/src/lib/viralSignals.ts`(similarAccountTone) +- `frontend/src/pages/CopyMissionDetailPage.tsx`(row 顯示 source badge、置信度小字) + +### 設計選擇備忘 +- `similarAccountsFromSummary` 只帶 summary 可見欄位(沒 `TopicRelevance/LastSeenAt/Status`,那些是 Phase 2)。Phase 1 schema 只補 `MatchedSource`,其他 Phase 2 欄位別在 Phase 1 先加,避免污染。 +- 排序權重改成 log-scale follower bucket(1k/10k/100k/1M cut),避免 mega 帳號壓過 niche 高相關作者。`ReferenceRankWeights` 結構在 `reference_accounts.go` 內部,未注入 input,未來要調整再加。 +- `ProfileURL` 從 `https://www.threads.net/@` 改 `https://www.threads.com/@`(threads.com 現在是 canonical host,前端 `frontend/src/lib/threadsLinks.ts` 用 `threadsProfileUrl(username, profileUrl)` 已能接受 `profile_url` injected,不需改)。 +- `MergeSimilarAccounts` 串接 priority:新(scan+web)> 舊(保舊不被清掉),命中兩條 source 的 username `MatchedSource` 會 union 含 scan 與 web,但 **confidence 不會自動升 high**(confidence 由 `BuildReferenceAccountsFromScan` 內 strict/relaxed gate + verified + bestEngagement 算出來)。若未來要把「跨來源」當 high confidence 的訊號,需在 `MergeSimilarAccounts` 或 mapper 後處理;目前只透過前端 `MatchedSource` badge 視覺呈現。 +- web search fallback 只在 `< MaxSimilarAccounts(=10)` 時觸發,避免每次 scan 都額外打 web 浪費 quota;也避免 web 來的帳號擠掉 scan 來的(因為 new 排前面)。 +- Brave `StartPublishedDate` 欄位加了但**沒 map 到 Brave 的 `freshness` 參數**(Brave API freshness 接受 `d/w/m/y` 而非 ISO date,要做轉換)。Phase 1 只做資訊不丟失;實際生效需要第二階段處理 Brave adapter 的 `freshness` mapping。Exa 已有支援。 + +### 沒動的檔案(確認 boundary) +- Placement / Persona / Brand schema 完全沒動。 +- 沒碰 `internal/handler/routes.go`、`internal/svc/service_context.go`、`internal/response`、`internal/middleware`。 +- 沒動 `internal/model/copy_mission/repository`、`internal/model/copy_mission/domain/repository`。 + +--- + +> 範圍:僅 CopyMission flow。Placement(B 流海巡獲客)刻意維持「無對標帳號」設計,本計畫不動其 schema。 +> 上限:`MaxSimilarAccounts` 自 5 放寬到 10。 +> 遵循:`code/message/data` envelope、`page/pageSize`、`SSCCCDDD` 錯誤碼、UTC+0 nanoseconds、Redis `workerID` lock、不裸 `Update` job 狀態。 +> 驗證:`go mod tidy && make fmt && go test ./...`;前端動到另執行 `make web-build`。 + +--- + +## 背景:threads-api-skill repo 對位 + +參考 repo `madebypan/threads-api-skill` 是**純發布工具包**(容器→publish 30s wait、reply_to_id 串貼、catbox.moe 圖床、60 天 token refresh、Chrome 自動化 setup),**沒有「找相似帳號 / 找對標 / 找 TA」功能**。它整理的 Threads Graph API endpoint 與踩坑,可用來校正下列事實: + +- `me?fields=id,name,username,...` 取 self 資訊 → 目前 `threadsapi/client.go` 完全沒用,無法驗 token 對應 username。 +- `keyword_search`(TOP/RECENT,50/page,無 cursor)→ 已實作;目前沒做翻頁,召回量受限。 +- `media/{id}/replies` → 已實作,目前只當 outreach 標的,**沒把留言作者彙整成 TA 樣本**。 +- Threads Graph API **沒有 endpoint 查任意別人的 follower / 受眾輪廓** → 「找 TA」必須繞道:把留言作者/互動者彙整成潛在受眾樣本。 + +--- + +## 目前狀態(viral 流程摸整) + +- 唯一活著的相似帳號產生路徑:`scan_viral.go` job → `libviral.RunDiscover`(抓 threads 貼文)→ `BuildReferenceAccountsFromScan`(by author 聚合 + verified/follower/engagement 排序,strict gate→relaxed fallback)→ 寫入 `Mission.ResearchMap.SimilarAccounts`(`reference_accounts.go:43`)。 +- `DiscoverSimilarAccounts` / `EnrichSimilarAccounts` / `ToEntitySimilarAccounts` 是 dead code(只有測試呼叫)。 +- `analyze_copy_mission.go:138` 顯式 `researchMap.SimilarAccounts = nil` → 重新跑研究地圖 job 會清空相似帳號,前端沒警告。 +- API 對相似帳號純唯讀:`UpdateCopyMissionReq`(`copy_mission.api:60-72`)沒 `similar_accounts` 欄位,使用者無法排除/釘選。 +- 「找 TA」目前只是 LLM 產出的一段文字 `AudienceSummary`,**沒有任何「找出受眾樣本帳號」的機制**,連結構都沒有。 +- 驗證缺位:`ProfileURL` 一律 `https://www.threads.net/@` 拼字串(`reference_accounts.go:137`、`enrich_accounts.go:77`、`discover_accounts.go:119`),不驗真。 +- 純 Threads API 模式下 `AuthorVerified=false, FollowerCount=0` 恆成立,排序權重失衡。 +- 排序權重寫死:`reference_accounts.go:108-119` 固定 verified→follower→engagement,沒有主題 pillar 相似度、沒有跨任務記憶、沒有使用者可調。 +- `postTopicRelevant`(`reference_accounts.go:166-180`)用裸 `strings.Contains`,中文長 label 命中精度低。 + +--- + +## Phase 1 — 資料品質低風險修正(先做) + +### 1.1 名額放寬 +- `backend/internal/library/viral/discover_accounts.go:14`:`MaxSimilarAccounts = 5` → `10`。 +- `scan_viral.go:177`:`Limit: libviral.MaxSimilarAccounts` 維持引用常數。 +- 前端已用 list render,不需改。 + +### 1.2 停止 `analyze_copy_mission` 清空相似帳號 +- `backend/internal/worker/job/analyze_copy_mission.go:138`:刪 `researchMap.SimilarAccounts = nil`。 +- 確認 `model/copy_mission/usecase/usecase.go` dot-path 更新其他 research_map 欄位時不會覆蓋 `similar_accounts`(worker 才會用 `Patch.ResearchMap` 整包,且 worker 現在會保留 SimilarAccounts)。 + +### 1.3 scan 結果改合併而非覆蓋 +- `scan_viral.go:189-197`:`SimilarAccounts: referenceAccounts` → `SimilarAccounts: mergeSimilarAccounts(mission.ResearchMap.SimilarAccounts, referenceAccounts)`。 +- 新檔 `backend/internal/library/viral/merge_accounts.go`:by username 合併,新來的更新統計、舊有保留;Phase 1 schema 還沒 `Status`,先純保舊不被清掉。 +- `mergeSimilarAccounts(prev, new []missionentity.SimilarAccount) []missionentity.SimilarAccount`: + - key = lowercase username;新>舊(更新統計欄位),舊沒被新覆蓋就保留。 + - 排序:保留新來秩序在前 N 個,舊的接在後面直到達 `MaxSimilarAccounts`。 + +### 1.4 排序權重抽出 + 主題相關改用命中詞數 +- `reference_accounts.go:108-119`:抽出 `ReferenceRankWeights` struct(`VerifiedW/FollowerW/TotalEngagementW/BestEngagementW/TopicRelevanceW`,預設 `4/2/1/1/2`),排序 key = 加權和。 +- `reference_accounts.go:166-180` `postTopicRelevant`:仍維持「命中詞數 >= 1 才納入」,但額外回傳 `topicHitCount` 給排序用。 +- `referenceAuthorAgg` 加 `topicHits int`;`BuildReferenceAccountsFromScan` 內部迴圈累加。 +-硼切詞暫不引入 jieba:對 CJK 用 `strings.Fields` + 拆字元組(>=2 字才當 term)即可,避免新依賴。 + +### 1.5 web search 補洞(復活 dead code 線) +- `scan_viral.go:172` `BuildReferenceAccountsFromScan` 之後,若 `len(referenceAccounts) < 5` 且 `memberCtx.WebSearchEnabled()`: + ```go + extra, err := libviral.DiscoverSimilarAccounts(ctx, websearchClient, libviral.DiscoverAccountsInput{ + SeedQuery: mission.SeedQuery, + Brief: mission.Brief, + Pillars: mission.ResearchMap.Pillars, + }) + if err == nil && len(extra) > 0 { + referenceAccounts = libviral.EnrichSimilarAccounts(referenceAccounts, nil, libviral.MaxSimilarAccounts) + // 把 web 找到的也 by username 合併進去 + } + ``` +- `enrich_accounts.go`:`Source` 改成集合概念:`viral.SimilarAccount` 與 `missionentity.SimilarAccount` 都加 `MatchedSource []string`,命中 web 也命中 scan = `confidence="high"`。 +- web search 失敗**不可讓 scan job 失敗**,只跳過並 log。 + +### 1.6 threadsapi profile URL helper +- 新檔 `backend/internal/library/threadsapi/url.go`:`ProfileURLFromPermalink(permalink, username) string`: + - regex `threads\.(?:com|net)/@([^/]+)/post/` 從 permalink 抓 author root `https://www.threads.com/@/`。 + - fallback `https://www.threads.com/@`(注意 `www.threads.com` 而非 `www.threads.net` 較新且穩)。 +- `reference_accounts.go:137`、`enrich_accounts.go:77`、`discover_accounts.go:119` 改用此 helper。 + +### 1.7 Brave adapter 補 StartPublishedDate +- `backend/internal/library/websearch/client.go:114-127` Brave adapter:把 `SearchOptions.StartPublishedDate`(即便 Brave API 不支援)也讀進 `libbrave.SearchOptions`,讓未來 Brave 端支援時自動生效。實作細節:libbrave.SearchOptions 若沒對應欄位就先無效果,但頂層 client 不能丟失資訊。 + +### 1.8 前端顯示新增資訊 +- `frontend/src/pages/CopyMissionDetailPage.tsx:837-875` row:加 `source` badge(已有 `AccountSource` 文字時顯示 `brand`/`neutral`)、`confidence` 用 `text-muted` 小字。 +- `frontend/src/lib/viralSignals.ts:42-48` `viralAccountRowClass`:依 Phase 1 schema 補 `MatchedSource` → 範含 `scan` 與 `web` 用 `--verified-hot` 樣式。 +- `frontend/src/types/copyMission.ts`:`SimilarAccount` 加 `matched_source?: string[]`(Phase 1 只補這欄位,舊資料缺少不影響渲染)。 +- 執行 `make web-build`。 + +--- + +## Phase 2 — 相似帳號變一等公民(動 API,CopyMission only) + +### 2.1 schema 變更 +`backend/internal/model/copy_mission/domain/entity/mission.go:23-35` `SimilarAccount` 加: +```go +Status string `bson:"status,omitempty" json:"status,omitempty"` // recommended|pinned|excluded|promoted +TopicRelevance float64 `bson:"topic_relevance,omitempty" json:"topic_relevance,omitempty"` +LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"` +MatchedSource []string `bson:"matched_source,omitempty" json:"matched_source,omitempty"` +``` +`internal/types/types.go` 同步(重新 gen-api)。 + +### 2.2 API 新增 +`backend/generate/api/copy_mission.api` 加: +``` +@handler patchCopyMissionSimilarAccount +patch /personas/:persona_id/copy-missions/:copy_mission_id/similar-accounts/:username (PatchSimilarAccountReq) returns (CommonRes) + +type PatchSimilarAccountReq { + Status string `json:"status"` +} +``` +跑 `make gen-api` 重新產 handler/logic/types。logic 走 dot-path: +```go +out[fmt.Sprintf("research_map.similar_accounts.%s.status", username)] = strings.TrimSpace(req.Status) +``` +錯誤碼:CopyMission 目前沒有專屬 scope,**Phase 2 用 `Facade` (10)** 暫時頂著,待新增 `CopyMission Scope=41` 後再回頭改。**禁止裸寫數字**,一律 `errs.For(code.Facade).ResInvalidState("similar account status not allowed")`。 + +### 2.3 worker 尊重 status +- `reference_accounts.go:54` 開頭:`if isExcluded(in.ExcludedUsernames, post.Author) { continue }`。 +- `scan_viral.go:170`:把 `mission.ResearchMap.SimilarAccounts` 中 `status=excluded` 的 username 傳進 `ReferenceAccountInput.ExcludedUsernames`。 +- `merge_accounts.go`:`status=pinned` 永遠置頂,本次沒掃到也保留。 + +### 2.4 前端互動 +- `CopyMissionDetailPage.tsx` row 加兩按鈕「釘選」「排除」,呼 `api.patch(.../similar-accounts/:username, {status})`,本地 optimistic update。 +- 新檔 `frontend/src/lib/copyMissionSimilarAccounts.ts` 包 API client。 +- `CopyMissionResearchOverview.tsx` 加「相似帳號」子區(從 detail 頁整合進來)。 + +--- + +## Phase 3 — TA 樣本(留言作者採樣,全新功能) + + Threads API 拿不到任意別人的受眾 → 繞道:用 `MediaReplies` 的 `ReplyCandidate.Author` 聚合成潛在受眾樣本。 + +### 3.1 schema 變更 +`backend/internal/model/copy_mission/domain/entity/mission.go` `ResearchMap` 加: +```go +AudienceSamples []AudienceSample `bson:"audience_samples,omitempty" json:"audience_samples,omitempty"` +``` +新 struct: +```go +type AudienceSample struct { + Username string `bson:"username" json:"username"` + SamplePostID string `bson:"sample_post_id,omitempty" json:"sample_post_id,omitempty"` + SampleText string `bson:"sample_text,omitempty" json:"sample_text,omitempty"` + ReplyLikeCount int `bson:"reply_like_count,omitempty" json:"reply_like_count,omitempty"` + Appearances int `bson:"appearances,omitempty" json:"appearances,omitempty"` + FirstSeenAt int64 `bson:"first_seen_at" json:"first_seen_at"` + LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"` + Status string `bson:"status,omitempty" json:"status,omitempty"` +} +``` +`generate/api/copy_mission.api` 在 `CopyResearchMap` 加 `AudienceSamples []CopyAudienceSampleData`;`make gen-api`。 + +### 3.2 新 library +`backend/internal/library/viral/audience.go`: +```go +func BuildAudienceSamplesFromReplies(replies []placement.ReplyCandidate, opts AudienceOpts) []missionentity.AudienceSample +``` +- `appearance = by username.Count` +- 過濾:自己 mission 的 author(即 SimilarAccount source)排除、純連結/數字罐頭 reply 文字過濾(`len < 5 chars` or URL-only)。 +- 排序 `appearance desc → replyLikeCount desc → postedAt desc`,前 15 筆。 +- confidence(前端呈現用,不入 struct):`appearance >= 3` high、`= 2` medium、`= 1` low。 + +### 3.3 worker 接入 +`scan_viral.go` 在 `AttachReplies` 完成後: +```go +var allReplies []placement.ReplyCandidate +for _, p := range postsWithReplies { + allReplies = append(allReplies, p.Replies...) +} +samples := libviral.BuildAudienceSamplesFromReplies(allReplies, libviral.AudienceOpts{ + Max: 15, + ExcludeAuthors: similarAccountUsernames, +}) +researchMap.AudienceSamples = mergeAudienceSamples(prev.Samples, samples) +``` +合併邏輯與 `merge_accounts.go` 同款。 + +### 3.4 API +- `GET /copy-missions/:id` 已含(在 `research_map.audience_samples`)。 +- `PATCH /copy-missions/:id/audience-samples/:username` body `{status}`,dot-path 更新 `research_map.audience_samples..status`。 +- `make gen-api` + 邏輯同 2.2。 + +### 3.5 前端 +- `CopyMissionResearchOverview.tsx` 新區「TA 樣本」(在「相似帳號」之後): + - 每筆 `@username`(連 `threadsProfileUrl`)、出現 N 篇、最近一次樣本文字。 + - 按鈕「收藏為長期受眾」(status=pinned)、「忽略」(status=excluded)。 +- 新檔 `frontend/src/lib/copyMissionAudience.ts` 包 API client。 +- `frontend/src/index.css` 加 `.hx-audience-samples*` token,沿用 `hx-copy-similar-accounts` 結構。 + +### 3.6 Phase 3 驗收 +- `make web-build` 過、`go test ./...` 過。 +- E2E:開 Threads API 連線 → 跑 scan job → detail page 顯示「TA 樣本」最多 15 筆、@username 連結不 404、可釘選後重跑 scan 不消失。 + +--- + +## Phase 4 — 跨任務記憶(後續,本計畫不實作) + +`ThreadsAccount` 層加 `KnownAccounts map[username]AccountProfile{ FirstSeenAt, LastSeenAt, Missions[], PersonaIds[], Tags[], AvgEngagement, Status }`,scan job 結束 upsert。同一 username 在多 mission 出現 → confidence 累積、auto-suggest 為 persona benchmark;已 excluded 跨任務繼承。 + +--- + +## 移交檢查表 + +### 要動的檔案(Phase 1) +**Go 後端** +- `internal/library/viral/discover_accounts.go`(常數、ProfileURL helper) +- `internal/library/viral/reference_accounts.go`(排序權重、topicHits) +- `internal/library/viral/enrich_accounts.go`(MatchedSource 集合) +- `internal/library/viral/merge_accounts.go`(新檔) +- `internal/library/threadsapi/url.go`(新檔,profileURL helper) +- `internal/library/websearch/client.go`(Brave 補 StartPublishedDate) +- `internal/worker/job/scan_viral.go`(web 補洞、merge 取代覆蓋) +- `internal/worker/job/analyze_copy_mission.go:138`(停止清空) +- `internal/model/copy_mission/domain/entity/mission.go`(SimilarAccount 加 MatchedSource) +- `internal/types/types.go` 同步(gen-api 或手動加) + +**前端(Phase 1.8)** +- `src/pages/CopyMissionDetailPage.tsx`(顯示 source/confidence) +- `src/lib/viralSignals.ts`(MatchedSource badge 對應) +- `src/types/copyMission.ts`(加 matched_source) + +### 要動的檔案(Phase 2-3,後續 agent) +- `model/copy_mission/domain/entity/mission.go`(Status、TopicRelevance、LastSeenAt、AudienceSample、AudienceSamples) +- `generate/api/copy_mission.api`(兩支 PATCH endpoint + types) +- `internal/logic/copy_mission/patch_similar_account_logic.go` + `patch_audience_sample_logic.go`(gen 產出後手寫) +- `internal/library/viral/audience.go`(新檔) +- `internal/worker/job/scan_viral.go`(excluded 傳遞、audience samples、pinned 保留) +- `frontend/src/components/CopyMissionResearchOverview.tsx`(整合區) +- `frontend/src/lib/copyMissionSimilarAccounts.ts`、`copyMissionAudience.ts`(新) +- `frontend/src/index.css`(`.hx-audience-samples*`) + +### 錯誤碼配置 +- Phase 2 patch similar 錯誤:CopyMission 無專屬 scope → 先用 `errs.For(code.Facade).ResInvalidState("similar account status not allowed")`;待新增 `CopyMission=41` 後再回頭改。**禁止裸寫數字**。 + +### 失敗安全 +- 任何 web search 呼叫失敗不能讓 scan job fail,只跳過。 +- `mergeSimilarAccounts` 必須處理 prev=nil。 +- `AudienceSamples`(Phase 3)在無 Threads API token 時直接跳過整段,不報錯。 \ No newline at end of file diff --git a/backend/generate/api/copy_mission.api b/backend/generate/api/copy_mission.api index 1d1c492..9e8ab69 100644 --- a/backend/generate/api/copy_mission.api +++ b/backend/generate/api/copy_mission.api @@ -9,17 +9,39 @@ type ( } CopySimilarAccountData { - Username string `json:"username"` - Reason string `json:"reason,omitempty"` - Source string `json:"source,omitempty"` - Confidence string `json:"confidence,omitempty"` - ProfileUrl string `json:"profile_url,omitempty"` - AuthorVerified bool `json:"author_verified,omitempty"` - FollowerCount int `json:"follower_count,omitempty"` - EngagementScore int `json:"engagement_score,omitempty"` - LikeCount int `json:"like_count,omitempty"` - ReplyCount int `json:"reply_count,omitempty"` - PostCount int `json:"post_count,omitempty"` + Username string `json:"username"` + Reason string `json:"reason,omitempty"` + Source string `json:"source,omitempty"` + MatchedSource []string `json:"matched_source,omitempty"` + Confidence string `json:"confidence,omitempty"` + Status string `json:"status,omitempty"` + TopicRelevance float64 `json:"topic_relevance,omitempty"` + LastSeenAt int64 `json:"last_seen_at,omitempty"` + ProfileUrl string `json:"profile_url,omitempty"` + AuthorVerified bool `json:"author_verified,omitempty"` + FollowerCount int `json:"follower_count,omitempty"` + EngagementScore int `json:"engagement_score,omitempty"` + LikeCount int `json:"like_count,omitempty"` + ReplyCount int `json:"reply_count,omitempty"` + PostCount int `json:"post_count,omitempty"` + } + + CopyAudienceSampleData { + Username string `json:"username"` + SamplePostId string `json:"sample_post_id,omitempty"` + SampleText string `json:"sample_text,omitempty"` + ReplyLikeCount int `json:"reply_like_count,omitempty"` + Appearances int `json:"appearances,omitempty"` + FirstSeenAt int64 `json:"first_seen_at"` + LastSeenAt int64 `json:"last_seen_at,omitempty"` + Status string `json:"status,omitempty"` + } + + CopyMissionResearchItemData { + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + Snippet string `json:"snippet,omitempty"` + Query string `json:"query,omitempty"` } CopyMissionResearchMapData { @@ -28,11 +50,49 @@ type ( Questions []string `json:"questions,omitempty"` Pillars []string `json:"pillars,omitempty"` Exclusions []string `json:"exclusions,omitempty"` + KnowledgeItems []string `json:"knowledge_items,omitempty"` + SelectedKnowledgeItems []string `json:"selected_knowledge_items,omitempty"` + ResearchItems []CopyMissionResearchItemData `json:"research_items,omitempty"` SuggestedTags []CopySuggestedTagData `json:"suggested_tags,omitempty"` SimilarAccounts []CopySimilarAccountData `json:"similar_accounts,omitempty"` + AudienceSamples []CopyAudienceSampleData `json:"audience_samples,omitempty"` BenchmarkNotes string `json:"benchmark_notes,omitempty"` } + ExpandCopyMissionGraphHandlerReq { + CopyMissionPath + ExpandKnowledgeGraphReq + } + + PatchCopyMissionGraphNodesHandlerReq { + CopyMissionPath + PatchKnowledgeGraphNodesReq + } + + PatchSimilarAccountReq { + Status string `json:"status"` + } + + PatchAudienceSampleReq { + Status string `json:"status"` + } + + CopyMissionSimilarAccountPath { + PersonaID string `path:"personaId" validate:"required"` + ID string `path:"id" validate:"required"` + Username string `path:"username" validate:"required"` + } + + PatchSimilarAccountHandlerReq { + CopyMissionSimilarAccountPath + PatchSimilarAccountReq + } + + PatchAudienceSampleHandlerReq { + CopyMissionSimilarAccountPath + PatchAudienceSampleReq + } + CopyMissionData { ID string `json:"id"` PersonaID string `json:"persona_id"` @@ -66,6 +126,8 @@ type ( Questions []string `json:"questions,optional"` Pillars []string `json:"pillars,optional"` Exclusions []string `json:"exclusions,optional"` + KnowledgeItems []string `json:"knowledge_items,optional"` + SelectedKnowledgeItems []string `json:"selected_knowledge_items,optional"` BenchmarkNotes *string `json:"benchmark_notes,optional"` SelectedTags []string `json:"selected_tags,optional"` Status *string `json:"status,optional"` @@ -102,6 +164,15 @@ type ( PersonaID string `path:"personaId" validate:"required"` } + CopyMissionInspirationReq { + Keyword string `json:"keyword,optional"` + } + + CopyMissionInspirationHandlerReq { + PersonaCopyMissionsPath + CopyMissionInspirationReq + } + CreateCopyMissionHandlerReq { PersonaCopyMissionsPath CreateCopyMissionReq @@ -182,6 +253,20 @@ type ( Total int `json:"total"` } + DeleteCopyMissionMatrixDraftsReq { + DraftIDs []string `json:"draft_ids,optional"` + } + + DeleteCopyMissionMatrixDraftsHandlerReq { + CopyMissionPath + DeleteCopyMissionMatrixDraftsReq + } + + DeleteCopyMissionMatrixDraftsData { + Deleted int `json:"deleted"` + Message string `json:"message"` + } + CopyMissionInspirationSourceData { Query string `json:"query,omitempty"` Title string `json:"title,omitempty"` @@ -213,7 +298,7 @@ service gateway { get /:personaId/copy-missions (PersonaCopyMissionsPath) returns (ListCopyMissionsData) @handler inspireCopyMission - post /:personaId/copy-mission-inspiration (PersonaCopyMissionsPath) returns (CopyMissionInspirationData) + post /:personaId/copy-mission-inspiration (CopyMissionInspirationHandlerReq) returns (CopyMissionInspirationData) @handler createCopyMission post /:personaId/copy-missions (CreateCopyMissionHandlerReq) returns (CopyMissionData) @@ -248,9 +333,27 @@ service gateway { @handler listCopyMissionCopyDrafts get /:personaId/copy-missions/:id/copy-drafts (CopyMissionPath) returns (ListCopyMissionCopyDraftsData) + @handler deleteCopyMissionMatrixDrafts + post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData) + @handler getCopyMissionScanSchedule get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData) @handler upsertCopyMissionScanSchedule put /:personaId/copy-missions/:id/scan-schedule (UpsertCopyMissionScanScheduleHandlerReq) returns (CopyMissionScanScheduleData) -} \ No newline at end of file + + @handler patchCopyMissionSimilarAccount + patch /:personaId/copy-missions/:id/similar-accounts/:username (PatchSimilarAccountHandlerReq) returns (CopyMissionData) + + @handler patchCopyMissionAudienceSample + patch /:personaId/copy-missions/:id/audience-samples/:username (PatchAudienceSampleHandlerReq) returns (CopyMissionData) + + @handler expandCopyMissionGraph + post /:personaId/copy-missions/:id/knowledge-graph/expand (ExpandCopyMissionGraphHandlerReq) returns (ExpandKnowledgeGraphData) + + @handler getCopyMissionGraph + get /:personaId/copy-missions/:id/knowledge-graph (CopyMissionPath) returns (KnowledgeGraphData) + + @handler patchCopyMissionGraphNodes + patch /:personaId/copy-missions/:id/knowledge-graph/nodes (PatchCopyMissionGraphNodesHandlerReq) returns (KnowledgeGraphData) +} diff --git a/backend/generate/api/member.api b/backend/generate/api/member.api index 6fd1a58..5f2f546 100644 --- a/backend/generate/api/member.api +++ b/backend/generate/api/member.api @@ -50,6 +50,16 @@ type ( ExaUserLocation *string `json:"exa_user_location,optional"` ExpandStrategy *string `json:"expand_strategy,optional"` } + + MemberCapabilitiesData { + DiscoverReady bool `json:"discover_ready"` + AiReady bool `json:"ai_ready"` + PublishReady bool `json:"publish_ready"` + DiscoverHint string `json:"discover_hint,omitempty"` + AiHint string `json:"ai_hint,omitempty"` + PublishHint string `json:"publish_hint,omitempty"` + ActiveThreadsAccountId string `json:"active_threads_account_id,omitempty"` + } ) @server( @@ -71,4 +81,7 @@ service gateway { @handler updateMemberPlacementSettings patch /me/placement-settings (UpdateMemberPlacementSettingsReq) returns (MemberPlacementSettingsData) + + @handler getMemberCapabilities + get /me/capabilities returns (MemberCapabilitiesData) } diff --git a/backend/generate/api/persona.api b/backend/generate/api/persona.api index 9958077..4b364a1 100644 --- a/backend/generate/api/persona.api +++ b/backend/generate/api/persona.api @@ -183,6 +183,11 @@ type ( Status string `json:"status"` Message string `json:"message"` } + + DeleteCopyDraftData { + DraftID string `json:"draft_id"` + Message string `json:"message"` + } ) @server( @@ -228,4 +233,7 @@ service gateway { @handler publishPersonaCopyDraft post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData) + + @handler deletePersonaCopyDraft + delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData) } \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index c277ba1..288e93a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module haixun-backend -go 1.22 +go 1.23 require ( github.com/go-playground/validator/v10 v10.27.0 @@ -14,6 +14,8 @@ require ( ) require ( + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -24,6 +26,9 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect + github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 // indirect + github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/golang/snappy v1.0.0 // indirect github.com/grafana/pyroscope-go v1.2.7 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect diff --git a/backend/go.sum b/backend/go.sum index 1b87f59..0f9b29a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,7 @@ +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -30,6 +34,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w= +github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM= +github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 h1:A3B75Yp163FAIf9nLlFMl4pwIj+T3uKxfI7mbvvY2Ls= +github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0/go.mod h1:suxK0Wpz4BM3/2+z1mnOVTIWHDiMCIOGoKDCRumSsk0= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= @@ -61,6 +71,7 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -83,10 +94,12 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -94,6 +107,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -141,16 +155,35 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -159,20 +192,42 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY= google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0= diff --git a/backend/internal/handler/copy_mission/delete_copy_mission_matrix_drafts_handler.go b/backend/internal/handler/copy_mission/delete_copy_mission_matrix_drafts_handler.go new file mode 100644 index 0000000..fe3aeea --- /dev/null +++ b/backend/internal/handler/copy_mission/delete_copy_mission_matrix_drafts_handler.go @@ -0,0 +1,32 @@ +// 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" +) + +func DeleteCopyMissionMatrixDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.DeleteCopyMissionMatrixDraftsHandlerReq + 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 := copy_mission.NewDeleteCopyMissionMatrixDraftsLogic(r.Context(), svcCtx) + data, err := l.DeleteCopyMissionMatrixDrafts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/copy_mission/expand_copy_mission_graph_handler.go b/backend/internal/handler/copy_mission/expand_copy_mission_graph_handler.go new file mode 100644 index 0000000..37bdb69 --- /dev/null +++ b/backend/internal/handler/copy_mission/expand_copy_mission_graph_handler.go @@ -0,0 +1,32 @@ +// 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" +) + +func ExpandCopyMissionGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ExpandCopyMissionGraphHandlerReq + 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 := copy_mission.NewExpandCopyMissionGraphLogic(r.Context(), svcCtx) + data, err := l.ExpandCopyMissionGraph(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/copy_mission/get_copy_mission_graph_handler.go b/backend/internal/handler/copy_mission/get_copy_mission_graph_handler.go new file mode 100644 index 0000000..f8f8bc1 --- /dev/null +++ b/backend/internal/handler/copy_mission/get_copy_mission_graph_handler.go @@ -0,0 +1,32 @@ +// 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" +) + +func GetCopyMissionGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CopyMissionPath + 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 := copy_mission.NewGetCopyMissionGraphLogic(r.Context(), svcCtx) + data, err := l.GetCopyMissionGraph(&req) + response.Write(r.Context(), w, data, err) + } +} 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 7cd3524..5d0f751 100644 --- a/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go +++ b/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go @@ -13,7 +13,7 @@ import ( func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var req types.PersonaCopyMissionsPath + var req types.CopyMissionInspirationHandlerReq if err := httpx.Parse(r, &req); err != nil { response.Write(r.Context(), w, nil, response.WrapRequestError(err)) return diff --git a/backend/internal/handler/copy_mission/patch_copy_mission_audience_sample_handler.go b/backend/internal/handler/copy_mission/patch_copy_mission_audience_sample_handler.go new file mode 100644 index 0000000..a0a1f45 --- /dev/null +++ b/backend/internal/handler/copy_mission/patch_copy_mission_audience_sample_handler.go @@ -0,0 +1,32 @@ +// 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" +) + +func PatchCopyMissionAudienceSampleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PatchAudienceSampleHandlerReq + 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 := copy_mission.NewPatchCopyMissionAudienceSampleLogic(r.Context(), svcCtx) + data, err := l.PatchCopyMissionAudienceSample(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/copy_mission/patch_copy_mission_graph_nodes_handler.go b/backend/internal/handler/copy_mission/patch_copy_mission_graph_nodes_handler.go new file mode 100644 index 0000000..c09868c --- /dev/null +++ b/backend/internal/handler/copy_mission/patch_copy_mission_graph_nodes_handler.go @@ -0,0 +1,32 @@ +// 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" +) + +func PatchCopyMissionGraphNodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PatchCopyMissionGraphNodesHandlerReq + 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 := copy_mission.NewPatchCopyMissionGraphNodesLogic(r.Context(), svcCtx) + data, err := l.PatchCopyMissionGraphNodes(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/copy_mission/patch_copy_mission_similar_account_handler.go b/backend/internal/handler/copy_mission/patch_copy_mission_similar_account_handler.go new file mode 100644 index 0000000..6cb487b --- /dev/null +++ b/backend/internal/handler/copy_mission/patch_copy_mission_similar_account_handler.go @@ -0,0 +1,32 @@ +// 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" +) + +func PatchCopyMissionSimilarAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PatchSimilarAccountHandlerReq + 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 := copy_mission.NewPatchCopyMissionSimilarAccountLogic(r.Context(), svcCtx) + data, err := l.PatchCopyMissionSimilarAccount(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/member/get_member_capabilities_handler.go b/backend/internal/handler/member/get_member_capabilities_handler.go new file mode 100644 index 0000000..6f8a0ea --- /dev/null +++ b/backend/internal/handler/member/get_member_capabilities_handler.go @@ -0,0 +1,20 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "haixun-backend/internal/logic/member" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" +) + +func GetMemberCapabilitiesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := member.NewGetMemberCapabilitiesLogic(r.Context(), svcCtx) + data, err := l.GetMemberCapabilities() + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/persona/delete_persona_copy_draft_handler.go b/backend/internal/handler/persona/delete_persona_copy_draft_handler.go new file mode 100644 index 0000000..5b9c2a3 --- /dev/null +++ b/backend/internal/handler/persona/delete_persona_copy_draft_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 DeletePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CopyDraftPath + 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.NewDeletePersonaCopyDraftLogic(r.Context(), svcCtx) + data, err := l.DeletePersonaCopyDraft(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index cbc3596..6743303 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -261,6 +261,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:personaId/copy-missions/:id/analyze-jobs", Handler: copy_mission.StartCopyMissionAnalyzeJobHandler(serverCtx), }, + { + Method: http.MethodPatch, + Path: "/:personaId/copy-missions/:id/audience-samples/:username", + Handler: copy_mission.PatchCopyMissionAudienceSampleHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:personaId/copy-missions/:id/copy-draft-jobs", @@ -271,11 +276,31 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:personaId/copy-missions/:id/copy-drafts", Handler: copy_mission.ListCopyMissionCopyDraftsHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/:personaId/copy-missions/:id/knowledge-graph", + Handler: copy_mission.GetCopyMissionGraphHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:personaId/copy-missions/:id/knowledge-graph/expand", + Handler: copy_mission.ExpandCopyMissionGraphHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/:personaId/copy-missions/:id/knowledge-graph/nodes", + Handler: copy_mission.PatchCopyMissionGraphNodesHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:personaId/copy-missions/:id/matrix-drafts", Handler: copy_mission.GenerateCopyMissionMatrixHandler(serverCtx), }, + { + Method: http.MethodPost, + Path: "/:personaId/copy-missions/:id/matrix-drafts/delete", + Handler: copy_mission.DeleteCopyMissionMatrixDraftsHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:personaId/copy-missions/:id/matrix-jobs", @@ -301,6 +326,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:personaId/copy-missions/:id/scan-schedule", Handler: copy_mission.UpsertCopyMissionScanScheduleHandler(serverCtx), }, + { + Method: http.MethodPatch, + Path: "/:personaId/copy-missions/:id/similar-accounts/:username", + Handler: copy_mission.PatchCopyMissionSimilarAccountHandler(serverCtx), + }, }..., ), rest.WithPrefix("/api/v1/personas"), @@ -463,6 +493,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/me", Handler: member.UpdateMemberMeHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/me/capabilities", + Handler: member.GetMemberCapabilitiesHandler(serverCtx), + }, { Method: http.MethodGet, Path: "/me/placement-settings", @@ -547,6 +582,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/copy-drafts/:draftId", Handler: persona.UpdatePersonaCopyDraftHandler(serverCtx), }, + { + Method: http.MethodDelete, + Path: "/:id/copy-drafts/:draftId", + Handler: persona.DeletePersonaCopyDraftHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/copy-drafts/:draftId/publish", diff --git a/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go b/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go index a461172..e7ebf6a 100644 --- a/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go +++ b/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go @@ -27,4 +27,4 @@ func ListThreadsAccountAiProviderModelsHandler(svcCtx *svc.ServiceContext) http. data, err := l.ListThreadsAccountAiProviderModels(&req) response.Write(r.Context(), w, data, err) } -} \ No newline at end of file +} diff --git a/backend/internal/library/brave/client.go b/backend/internal/library/brave/client.go index ffef308..a642061 100644 --- a/backend/internal/library/brave/client.go +++ b/backend/internal/library/brave/client.go @@ -58,6 +58,12 @@ type SearchOptions struct { Mode Mode Country string SearchLang string + // StartPublishedDate is accepted for forward-compatibility (Brave's + // `freshness` query parameter maps from this once wired). Current brave + // client keeps it as a passthrough hint and does not yet append it to the + // request, so callers that depend on date filtering should pair it with + // Exa (which honours startPublishedDate server-side). + StartPublishedDate string } func (c *Client) Search(ctx context.Context, opts SearchOptions) (SearchResponse, error) { diff --git a/backend/internal/library/copymission/knowledge.go b/backend/internal/library/copymission/knowledge.go new file mode 100644 index 0000000..80d79a3 --- /dev/null +++ b/backend/internal/library/copymission/knowledge.go @@ -0,0 +1,388 @@ +package copymission + +import ( + "strings" + + libkg "haixun-backend/internal/library/knowledge" + libmatrix "haixun-backend/internal/library/matrix" + missionentity "haixun-backend/internal/model/copy_mission/domain/entity" +) + +type MissionContext struct { + Label string + SeedQuery string + Brief string + ResearchMap missionentity.ResearchMap +} + +type PersonaContext struct { + DisplayName string + Persona string +} + +func PlanInput( + mission MissionContext, + seed string, + l1Labels []string, + supplemental bool, + strategy libkg.ExpandStrategy, +) libkg.PlanInput { + return libkg.PlanInput{ + Seed: seed, + TargetAudience: strings.TrimSpace(mission.ResearchMap.AudienceSummary), + ProductBrief: strings.TrimSpace(mission.Brief), + Pillars: mission.ResearchMap.Pillars, + Questions: mission.ResearchMap.Questions, + L1Labels: l1Labels, + Supplemental: supplemental, + Strategy: strategy, + } +} + +func SynthInput(mission MissionContext, persona PersonaContext, sources []libkg.BraveSource) libkg.SynthInput { + label := strings.TrimSpace(mission.Label) + if label == "" { + label = strings.TrimSpace(mission.SeedQuery) + } + return libkg.SynthInput{ + BrandDisplayName: label, + TopicName: label, + Seed: strings.TrimSpace(mission.SeedQuery), + ProductBrief: strings.TrimSpace(mission.Brief), + TargetAudience: strings.TrimSpace(mission.ResearchMap.AudienceSummary), + Persona: strings.TrimSpace(persona.Persona), + ResearchPillars: mission.ResearchMap.Pillars, + ResearchQuestions: mission.ResearchMap.Questions, + Sources: sources, + } +} + +func PatrolTagInput(mission MissionContext) libkg.PatrolTagInput { + return libkg.PatrolTagInput{ + Questions: mission.ResearchMap.Questions, + Pillars: mission.ResearchMap.Pillars, + } +} + +func FormatNodeKnowledge(node libkg.Node) string { + label := strings.TrimSpace(node.Label) + if label == "" { + return "" + } + detail := strings.TrimSpace(node.PlacementValue) + if detail == "" { + detail = strings.TrimSpace(node.Relation) + } + if detail != "" { + return label + ":" + detail + } + return label +} + +// FormatNodeKnowledgeForMatrix includes label, detail, patrol tags, and evidence for copy generation. +func FormatNodeKnowledgeForMatrix(node libkg.Node) string { + label := strings.TrimSpace(node.Label) + if label == "" { + return "" + } + var parts []string + if detail := strings.TrimSpace(node.PlacementValue); detail != "" { + parts = append(parts, detail) + } else if detail := strings.TrimSpace(node.Relation); detail != "" { + parts = append(parts, detail) + } + tags := append(append([]string{}, node.DerivedTags.Relevance...), node.DerivedTags.Recency...) + if len(tags) > 0 { + parts = append(parts, "標籤:"+strings.Join(tags, "、")) + } + if len(node.Evidence) > 0 { + if snippet := strings.TrimSpace(node.Evidence[0].Snippet); snippet != "" { + parts = append(parts, "參考:"+snippet) + } + } + if len(parts) == 0 { + return label + } + return label + "|" + strings.Join(parts, ";") +} + +func KnowledgeItemsFromNodes(nodes []libkg.Node) (all []string, selected []string) { + seen := map[string]struct{}{} + for _, node := range nodes { + line := FormatNodeKnowledge(node) + if line == "" { + continue + } + key := strings.ToLower(line) + if _, ok := seen[key]; ok { + if node.SelectedForScan { + selected = mergeLine(selected, line) + } + continue + } + seen[key] = struct{}{} + all = append(all, line) + if node.SelectedForScan { + selected = append(selected, line) + } + } + return all, selected +} + +func ResearchItemsFromSources(sources []libkg.BraveSource) []missionentity.ResearchItem { + items := make([]missionentity.ResearchItem, 0, len(sources)) + for _, src := range sources { + if strings.TrimSpace(src.URL) == "" && strings.TrimSpace(src.Snippet) == "" { + continue + } + items = append(items, missionentity.ResearchItem{ + Title: src.Title, + URL: src.URL, + Snippet: src.Snippet, + Query: src.Query, + }) + } + return items +} + +func ApplyDefaultNodeSelection(nodes []libkg.Node) { + ApplyDefaultNodeSelectionPreserving(nodes, nil) +} + +func ApplyDefaultNodeSelectionPreserving(nodes []libkg.Node, preserve map[string]bool) { + for i := range nodes { + if preserve != nil { + if sel, ok := preserve[nodes[i].ID]; ok { + nodes[i].SelectedForScan = sel + continue + } + } + kind := strings.TrimSpace(nodes[i].NodeKind) + nodes[i].SelectedForScan = kind == "pain" || + kind == "symptom" || + kind == "cause" || + nodes[i].ProductFitScore >= 70 + } +} + +func mergeLine(list []string, line string) []string { + for _, item := range list { + if item == line { + return list + } + } + return append(list, line) +} + +func mergeKnowledgeLines(base, extra []string) []string { + out := append([]string(nil), base...) + seen := map[string]struct{}{} + for _, item := range out { + seen[strings.ToLower(strings.TrimSpace(item))] = struct{}{} + } + for _, item := range extra { + item = strings.TrimSpace(item) + if item == "" { + continue + } + key := strings.ToLower(item) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} + +func knowledgeItemsFromNodesForMatrix(nodes []libkg.Node) (all []string, selected []string) { + seen := map[string]struct{}{} + for _, node := range nodes { + line := FormatNodeKnowledgeForMatrix(node) + if line == "" { + continue + } + key := strings.ToLower(line) + if _, ok := seen[key]; ok { + if node.SelectedForScan { + selected = mergeLine(selected, line) + } + continue + } + seen[key] = struct{}{} + all = append(all, line) + if node.SelectedForScan { + selected = append(selected, line) + } + } + return all, selected +} + +func knowledgeItemsFromSelectedTags(nodes []libkg.Node, tags []string) []string { + normalized := make([]string, 0, len(tags)) + seen := map[string]struct{}{} + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + key := strings.ToLower(tag) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, key) + } + if len(normalized) == 0 { + return nil + } + out := make([]string, 0) + lineSeen := map[string]struct{}{} + for _, node := range nodes { + if !nodeMatchesTags(node, normalized) { + continue + } + line := FormatNodeKnowledgeForMatrix(node) + if line == "" { + continue + } + if _, ok := lineSeen[line]; ok { + continue + } + lineSeen[line] = struct{}{} + out = append(out, line) + } + return out +} + +func nodeMatchesTags(node libkg.Node, tags []string) bool { + candidates := []string{node.Label, node.PlacementValue, node.Relation} + candidates = append(candidates, node.DerivedTags.Relevance...) + candidates = append(candidates, node.DerivedTags.Recency...) + for _, raw := range candidates { + lower := strings.ToLower(strings.TrimSpace(raw)) + if lower == "" { + continue + } + for _, tag := range tags { + if strings.Contains(lower, tag) { + return true + } + } + } + return false +} + +// MatrixKnowledgeItems uses graph-selected nodes as the source of truth and only merges +// manual-only selected_knowledge_items that are not graph-derived duplicates. +func MatrixKnowledgeItems(selected, _ []string, graphNodes []libkg.Node, _ []string) []string { + _, fromGraph := knowledgeItemsFromNodesForMatrix(graphNodes) + graphKeys := map[string]struct{}{} + for _, node := range graphNodes { + line := FormatNodeKnowledge(node) + if line == "" { + continue + } + graphKeys[strings.ToLower(line)] = struct{}{} + } + manualSelected := make([]string, 0, len(selected)) + for _, item := range selected { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, fromGraph := graphKeys[strings.ToLower(item)]; fromGraph { + continue + } + manualSelected = append(manualSelected, item) + } + return libmatrix.MergeKnowledgeItems(fromGraph, manualSelected) +} + +func manualResearchKnowledge(prev missionentity.ResearchMap, nodes []libkg.Node) (all []string, selected []string) { + graphKeys := map[string]struct{}{} + for _, node := range nodes { + line := FormatNodeKnowledge(node) + if line == "" { + continue + } + graphKeys[strings.ToLower(line)] = struct{}{} + } + selectedKeys := map[string]struct{}{} + for _, item := range prev.SelectedKnowledgeItems { + item = strings.TrimSpace(item) + if item == "" { + continue + } + selectedKeys[strings.ToLower(item)] = struct{}{} + } + for _, item := range prev.KnowledgeItems { + item = strings.TrimSpace(item) + if item == "" { + continue + } + lower := strings.ToLower(item) + if _, fromGraph := graphKeys[lower]; fromGraph { + continue + } + all = append(all, item) + if _, ok := selectedKeys[lower]; ok { + selected = append(selected, item) + } + } + return all, selected +} + +func graphKnowledgeDeselected(prev missionentity.ResearchMap) map[string]struct{} { + selectedKeys := map[string]struct{}{} + for _, item := range prev.SelectedKnowledgeItems { + item = strings.TrimSpace(item) + if item == "" { + continue + } + selectedKeys[strings.ToLower(item)] = struct{}{} + } + deselected := map[string]struct{}{} + for _, item := range prev.KnowledgeItems { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := selectedKeys[strings.ToLower(item)]; !ok { + deselected[strings.ToLower(item)] = struct{}{} + } + } + return deselected +} + +func BuildResearchMapFromGraph(prev missionentity.ResearchMap, nodes []libkg.Node, sources []libkg.BraveSource) missionentity.ResearchMap { + allItems, selectedItems := KnowledgeItemsFromNodes(nodes) + deselected := graphKnowledgeDeselected(prev) + if len(deselected) > 0 { + filtered := selectedItems[:0] + for _, line := range selectedItems { + if _, skip := deselected[strings.ToLower(line)]; skip { + continue + } + filtered = append(filtered, line) + } + selectedItems = filtered + } + manualAll, manualSelected := manualResearchKnowledge(prev, nodes) + allItems = mergeKnowledgeLines(allItems, manualAll) + selectedItems = mergeKnowledgeLines(selectedItems, manualSelected) + return missionentity.ResearchMap{ + AudienceSummary: prev.AudienceSummary, + ContentGoal: prev.ContentGoal, + Questions: append([]string(nil), prev.Questions...), + Pillars: append([]string(nil), prev.Pillars...), + Exclusions: append([]string(nil), prev.Exclusions...), + SuggestedTags: append([]missionentity.SuggestedTag(nil), prev.SuggestedTags...), + SimilarAccounts: append([]missionentity.SimilarAccount(nil), prev.SimilarAccounts...), + AudienceSamples: append([]missionentity.AudienceSample(nil), prev.AudienceSamples...), + BenchmarkNotes: prev.BenchmarkNotes, + KnowledgeItems: allItems, + SelectedKnowledgeItems: selectedItems, + ResearchItems: ResearchItemsFromSources(sources), + } +} \ No newline at end of file diff --git a/backend/internal/library/copymission/knowledge_test.go b/backend/internal/library/copymission/knowledge_test.go new file mode 100644 index 0000000..b2f6b45 --- /dev/null +++ b/backend/internal/library/copymission/knowledge_test.go @@ -0,0 +1,121 @@ +package copymission + +import ( + "testing" + + libkg "haixun-backend/internal/library/knowledge" + missionentity "haixun-backend/internal/model/copy_mission/domain/entity" +) + +func TestMatrixKnowledgeItems_PrefersGraphSelection(t *testing.T) { + nodes := []libkg.Node{ + {Label: "痛點A", NodeKind: "pain", SelectedForScan: true, PlacementValue: "細節A"}, + {Label: "周邊B", NodeKind: "context", SelectedForScan: false}, + } + items := MatrixKnowledgeItems(nil, nil, nodes, nil) + if len(items) != 1 { + t.Fatalf("items = %v, want 1 selected graph node", items) + } + if items[0] != "痛點A|細節A" { + t.Fatalf("items[0] = %q", items[0]) + } +} + +func TestMatrixKnowledgeItems_UsesMissionSelectionOnly(t *testing.T) { + items := MatrixKnowledgeItems( + []string{"受眾問題:怎麼選"}, + []string{"內容支柱:懶人包"}, + nil, + nil, + ) + if len(items) != 1 || items[0] != "受眾問題:怎麼選" { + t.Fatalf("items = %v", items) + } +} + +func TestMatrixKnowledgeItems_MergesGraphAndMission(t *testing.T) { + nodes := []libkg.Node{ + { + Label: "備孕營養", + PlacementValue: "葉酸與鋅很重要", + SelectedForScan: true, + }, + { + Label: "未勾選", + PlacementValue: "不應出現", + SelectedForScan: false, + DerivedTags: libkg.DerivedTags{ + Relevance: []string{"備孕飲食"}, + }, + }, + } + items := MatrixKnowledgeItems( + []string{"手動補充:睡前習慣"}, + nil, + nodes, + []string{"備孕飲食"}, + ) + if len(items) != 2 { + t.Fatalf("items = %v", items) + } +} + +func TestMatrixKnowledgeItems_IgnoresUnselectedTagMatches(t *testing.T) { + nodes := []libkg.Node{ + { + Label: "備孕營養", + PlacementValue: "葉酸與鋅很重要", + SelectedForScan: false, + DerivedTags: libkg.DerivedTags{ + Relevance: []string{"備孕飲食"}, + }, + }, + } + items := MatrixKnowledgeItems(nil, nil, nodes, []string{"備孕飲食"}) + if len(items) != 0 { + t.Fatalf("items = %v, want none without explicit selection", items) + } +} + +func TestBuildResearchMapFromGraph_RespectsManualDeselection(t *testing.T) { + prev := missionentity.ResearchMap{ + KnowledgeItems: []string{"圖譜痛點:細節"}, + SelectedKnowledgeItems: []string{}, + } + out := BuildResearchMapFromGraph(prev, []libkg.Node{ + {Label: "圖譜痛點", NodeKind: "pain", SelectedForScan: true, PlacementValue: "細節"}, + }, nil) + if len(out.SelectedKnowledgeItems) != 0 { + t.Fatalf("selected = %v, want manual deselection honored", out.SelectedKnowledgeItems) + } +} + +func TestBuildResearchMapFromGraph_PreservesManualKnowledge(t *testing.T) { + prev := missionentity.ResearchMap{ + KnowledgeItems: []string{"手動知識:睡前"}, + SelectedKnowledgeItems: []string{"手動知識:睡前"}, + } + out := BuildResearchMapFromGraph(prev, []libkg.Node{ + {Label: "圖譜痛點", NodeKind: "pain", SelectedForScan: true, PlacementValue: "細節"}, + }, nil) + if len(out.KnowledgeItems) != 2 { + t.Fatalf("knowledge_items = %v", out.KnowledgeItems) + } + if len(out.SelectedKnowledgeItems) != 2 { + t.Fatalf("selected = %v", out.SelectedKnowledgeItems) + } +} + +func TestApplyDefaultNodeSelectionPreserving(t *testing.T) { + nodes := []libkg.Node{ + {ID: "keep", Label: "已勾", NodeKind: "context", SelectedForScan: false}, + {ID: "new", Label: "新痛點", NodeKind: "pain", SelectedForScan: false}, + } + ApplyDefaultNodeSelectionPreserving(nodes, map[string]bool{"keep": true}) + if !nodes[0].SelectedForScan { + t.Fatal("expected preserved selection on existing node") + } + if !nodes[1].SelectedForScan { + t.Fatal("expected default selection on new pain node") + } +} \ No newline at end of file diff --git a/backend/internal/library/copymission/matrix_lock.go b/backend/internal/library/copymission/matrix_lock.go new file mode 100644 index 0000000..0410f26 --- /dev/null +++ b/backend/internal/library/copymission/matrix_lock.go @@ -0,0 +1,55 @@ +package copymission + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + goredis "github.com/redis/go-redis/v9" +) + +const matrixReplaceLockTTL = 10 * time.Minute + +// WithMatrixReplaceLock serializes matrix draft replacement for one copy mission. +func WithMatrixReplaceLock( + ctx context.Context, + redis *goredis.Client, + tenantID, missionID, holder string, + fn func() error, +) error { + if fn == nil { + return nil + } + tenantID = strings.TrimSpace(tenantID) + missionID = strings.TrimSpace(missionID) + holder = strings.TrimSpace(holder) + if tenantID == "" || missionID == "" { + return fn() + } + if redis == nil { + return fn() + } + key := fmt.Sprintf("haixun:copy_matrix:%s:%s", tenantID, missionID) + if holder == "" { + holder = "matrix-replace" + } + ok, err := redis.SetNX(ctx, key, holder, matrixReplaceLockTTL).Result() + if err != nil { + return err + } + if !ok { + return errors.New("copy matrix replacement already in progress") + } + defer func() { + script := ` +if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) +end +return 0 +` + _ = redis.Eval(ctx, script, []string{key}, holder).Err() + }() + return fn() +} \ No newline at end of file diff --git a/backend/internal/library/copymission/reference_urls.go b/backend/internal/library/copymission/reference_urls.go new file mode 100644 index 0000000..1f32578 --- /dev/null +++ b/backend/internal/library/copymission/reference_urls.go @@ -0,0 +1,66 @@ +package copymission + +import ( + "strings" + + libkg "haixun-backend/internal/library/knowledge" +) + +// ReferenceURLsFromSelection collects evidence/source URLs tied to selected graph nodes. +func ReferenceURLsFromSelection(nodes []libkg.Node, sources []libkg.BraveSource) []string { + if len(nodes) == 0 { + return nil + } + selectedEvidence := map[string]struct{}{} + for _, node := range nodes { + if !node.SelectedForScan { + continue + } + for _, ev := range node.Evidence { + key := normalizeURLKey(ev.URL) + if key != "" { + selectedEvidence[key] = struct{}{} + } + } + } + if len(selectedEvidence) == 0 { + return nil + } + + out := make([]string, 0, len(selectedEvidence)) + seen := map[string]struct{}{} + appendURL := func(raw string) { + key := normalizeURLKey(raw) + if key == "" { + return + } + if _, ok := selectedEvidence[key]; !ok { + return + } + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + out = append(out, strings.TrimSpace(raw)) + } + for _, node := range nodes { + if !node.SelectedForScan { + continue + } + for _, ev := range node.Evidence { + appendURL(ev.URL) + } + } + for _, src := range sources { + appendURL(src.URL) + } + return out +} + +func normalizeURLKey(raw string) string { + u := strings.TrimSpace(raw) + if u == "" { + return "" + } + return strings.ToLower(strings.TrimRight(u, "/")) +} \ No newline at end of file diff --git a/backend/internal/library/copymission/reference_urls_test.go b/backend/internal/library/copymission/reference_urls_test.go new file mode 100644 index 0000000..0a89518 --- /dev/null +++ b/backend/internal/library/copymission/reference_urls_test.go @@ -0,0 +1,34 @@ +package copymission + +import ( + "testing" + + libkg "haixun-backend/internal/library/knowledge" +) + +func TestReferenceURLsFromSelection(t *testing.T) { + nodes := []libkg.Node{ + { + ID: "n1", + SelectedForScan: true, + Evidence: []libkg.Evidence{ + {URL: "https://example.com/a"}, + }, + }, + { + ID: "n2", + SelectedForScan: false, + Evidence: []libkg.Evidence{ + {URL: "https://example.com/b"}, + }, + }, + } + sources := []libkg.BraveSource{ + {URL: "https://example.com/a"}, + {URL: "https://example.com/c"}, + } + urls := ReferenceURLsFromSelection(nodes, sources) + if len(urls) != 1 || urls[0] != "https://example.com/a" { + t.Fatalf("urls = %v", urls) + } +} \ No newline at end of file diff --git a/backend/internal/library/copymission/research_items.go b/backend/internal/library/copymission/research_items.go new file mode 100644 index 0000000..6aea779 --- /dev/null +++ b/backend/internal/library/copymission/research_items.go @@ -0,0 +1,79 @@ +package copymission + +import ( + "strings" +) + +type ResearchSource struct { + Title string + URL string + Snippet string + Query string +} + +// FormatResearchItemsForMatrix renders Brave research snippets for the matrix prompt. +func FormatResearchItemsForMatrix(items []ResearchSource) string { + if len(items) == 0 { + return "" + } + lines := make([]string, 0, len(items)) + seen := map[string]struct{}{} + for _, item := range items { + title := strings.TrimSpace(item.Title) + url := strings.TrimSpace(item.URL) + snippet := strings.TrimSpace(item.Snippet) + if title == "" && url == "" && snippet == "" { + continue + } + key := strings.ToLower(url) + if key == "" { + key = strings.ToLower(title + "|" + snippet) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + var parts []string + if title != "" { + parts = append(parts, title) + } + if url != "" { + parts = append(parts, url) + } + if snippet != "" { + parts = append(parts, snippet) + } + lines = append(lines, strings.Join(parts, "|")) + } + if len(lines) == 0 { + return "" + } + return strings.Join(lines, "\n") +} + +// MergeReferenceURLs combines graph-selected URLs with mission research item URLs. +func MergeReferenceURLs(selected []string, researchItems []ResearchSource) []string { + if len(researchItems) == 0 { + return selected + } + out := append([]string(nil), selected...) + seen := map[string]struct{}{} + for _, raw := range selected { + if key := normalizeURLKey(raw); key != "" { + seen[key] = struct{}{} + } + } + for _, item := range researchItems { + raw := strings.TrimSpace(item.URL) + key := normalizeURLKey(raw) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, raw) + } + return out +} \ No newline at end of file diff --git a/backend/internal/library/copymission/research_items_test.go b/backend/internal/library/copymission/research_items_test.go new file mode 100644 index 0000000..99a57c4 --- /dev/null +++ b/backend/internal/library/copymission/research_items_test.go @@ -0,0 +1,28 @@ +package copymission + +import ( + "strings" + "testing" +) + +func TestMergeReferenceURLsIncludesResearchItems(t *testing.T) { + urls := MergeReferenceURLs( + []string{"https://example.com/a"}, + []ResearchSource{ + {URL: "https://example.com/b", Title: "B"}, + {URL: "https://example.com/a", Title: "dup"}, + }, + ) + if len(urls) != 2 { + t.Fatalf("urls = %v", urls) + } +} + +func TestFormatResearchItemsForMatrix(t *testing.T) { + block := FormatResearchItemsForMatrix([]ResearchSource{ + {Title: "備孕指南", URL: "https://example.com/guide", Snippet: "葉酸補充重點"}, + }) + if !strings.Contains(block, "備孕指南") || !strings.Contains(block, "葉酸補充重點") { + t.Fatalf("block = %q", block) + } +} \ No newline at end of file diff --git a/backend/internal/library/copymission/research_sources.go b/backend/internal/library/copymission/research_sources.go new file mode 100644 index 0000000..f43c633 --- /dev/null +++ b/backend/internal/library/copymission/research_sources.go @@ -0,0 +1,19 @@ +package copymission + +import missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase" + +func ResearchSourcesFromSummaries(items []missiondomain.ResearchItemSummary) []ResearchSource { + if len(items) == 0 { + return nil + } + out := make([]ResearchSource, 0, len(items)) + for _, item := range items { + out = append(out, ResearchSource{ + Title: item.Title, + URL: item.URL, + Snippet: item.Snippet, + Query: item.Query, + }) + } + return out +} \ No newline at end of file diff --git a/backend/internal/library/knowledge/brave_collect.go b/backend/internal/library/knowledge/brave_collect.go index 3854699..f18e97b 100644 --- a/backend/internal/library/knowledge/brave_collect.go +++ b/backend/internal/library/knowledge/brave_collect.go @@ -4,7 +4,6 @@ import ( "context" "strings" "sync" - "sync/atomic" "haixun-backend/internal/library/websearch" ) @@ -22,6 +21,8 @@ type BraveCollectConfig struct { Concurrency int } +const minUsefulSourceRunes = 24 + func BraveCollectConfigFromQueryCfg(cfg queryConfig) BraveCollectConfig { out := BraveCollectConfig{ ResultsPerQuery: cfg.ResultsPerQuery, @@ -99,7 +100,7 @@ func collectWebSourcesSequential( seenURL := map[string]struct{}{} for i, query := range queries { - if shouldStopCollect(out, cfg) { + if shouldStopCollect(out, queries, cfg) { break } if heartbeat != nil { @@ -107,7 +108,7 @@ func collectWebSourcesSequential( return out } } - appendBraveResults(&out, seenURL, query, searchWebQuery(ctx, client, locale, query, cfg.ResultsPerQuery)) + appendBraveResults(&out, seenURL, query, searchWebQuery(ctx, client, locale, query, cfg.ResultsPerQuery), cfg.MaxSourcesCap) if onProgress != nil { onProgress(i, len(queries)) } @@ -116,39 +117,44 @@ func collectWebSourcesSequential( } type braveCollectState struct { - cfg BraveCollectConfig - mu sync.Mutex - out []BraveSource - seenURL map[string]struct{} - stop bool - completed int32 + cfg BraveCollectConfig + mu sync.Mutex + out []BraveSource + seenURL map[string]struct{} + stop bool } -func (s *braveCollectState) shouldStop(cfg BraveCollectConfig) bool { +func (s *braveCollectState) shouldStop(queries []string, cfg BraveCollectConfig) bool { s.mu.Lock() defer s.mu.Unlock() if s.stop { return true } - if shouldStopCollect(s.out, cfg) { + if shouldStopCollect(s.out, queries, cfg) { s.stop = true return true } return false } -func (s *braveCollectState) appendResults(query string, items []BraveSource) { +func (s *braveCollectState) appendResults(query string, items []BraveSource, queries []string) { s.mu.Lock() defer s.mu.Unlock() if s.stop { return } - appendBraveResults(&s.out, s.seenURL, query, items) - if shouldStopCollect(s.out, s.cfg) { + appendBraveResults(&s.out, s.seenURL, query, items, s.cfg.MaxSourcesCap) + if shouldStopCollect(s.out, queries, s.cfg) { s.stop = true } } +func (s *braveCollectState) snapshot() []BraveSource { + s.mu.Lock() + defer s.mu.Unlock() + return append([]BraveSource(nil), s.out...) +} + func collectWebSourcesParallel( ctx context.Context, client websearch.Client, @@ -172,45 +178,122 @@ func collectWebSourcesParallel( workers = 1 } - jobs := make(chan int, len(queries)) - for i := range queries { - jobs <- i - } - close(jobs) + completed := 0 + for next := 0; next < len(queries); { + if state.shouldStop(queries, cfg) { + break + } + batchEnd := next + workers + if batchEnd > len(queries) { + batchEnd = len(queries) + } - var wg sync.WaitGroup - for w := 0; w < workers; w++ { - wg.Add(1) - go func() { - defer wg.Done() - for i := range jobs { - if state.shouldStop(cfg) { - return - } - if heartbeat != nil { - if err := heartbeat(); err != nil { - return - } - } - query := queries[i] - items := searchWebQuery(ctx, client, locale, query, cfg.ResultsPerQuery) - state.appendResults(query, items) - done := int(atomic.AddInt32(&state.completed, 1)) - if onProgress != nil { - onProgress(done-1, len(queries)) + type queryResult struct { + index int + items []BraveSource + } + results := make(chan queryResult, batchEnd-next) + var wg sync.WaitGroup + for i := next; i < batchEnd; i++ { + if heartbeat != nil { + if err := heartbeat(); err != nil { + return state.snapshot() } } - }() + query := queries[i] + wg.Add(1) + go func(index int, q string) { + defer wg.Done() + results <- queryResult{ + index: index, + items: searchWebQuery(ctx, client, locale, q, cfg.ResultsPerQuery), + } + }(i, query) + } + wg.Wait() + close(results) + + ordered := make([]queryResult, batchEnd-next) + for result := range results { + ordered[result.index-next] = result + } + for _, result := range ordered { + state.appendResults(queries[result.index], result.items, queries) + completed++ + if onProgress != nil { + onProgress(completed-1, len(queries)) + } + } + next = batchEnd } - wg.Wait() - return state.out + return state.snapshot() } -func shouldStopCollect(out []BraveSource, cfg BraveCollectConfig) bool { +func shouldStopCollect(out []BraveSource, queries []string, cfg BraveCollectConfig) bool { if len(out) >= cfg.MaxSourcesCap { return true } - return len(out) >= cfg.MinSourcesBeforeStop && uniqueSourceCount(out) >= cfg.MinSourcesBeforeStop + return sourcesAreSufficient(out, queries, cfg) +} + +func sourcesAreSufficient(out []BraveSource, queries []string, cfg BraveCollectConfig) bool { + if cfg.MinSourcesBeforeStop <= 0 { + return len(out) > 0 + } + stats := sourceStats(out) + if stats.UniqueURLs < cfg.MinSourcesBeforeStop { + return false + } + minQueryCoverage := 1 + if len(queries) > 1 { + minQueryCoverage = 2 + } + if stats.QueryCoverage < minQueryCoverage { + return false + } + minUseful := (cfg.MinSourcesBeforeStop * 2) / 3 + if minUseful < 1 { + minUseful = 1 + } + if stats.UsefulSources < minUseful { + return false + } + return stats.TextRunes >= cfg.MinSourcesBeforeStop*minUsefulSourceRunes +} + +type collectSourceStats struct { + UniqueURLs int + QueryCoverage int + UsefulSources int + TextRunes int +} + +func sourceStats(items []BraveSource) collectSourceStats { + seenURL := map[string]struct{}{} + seenQuery := map[string]struct{}{} + stats := collectSourceStats{} + for _, item := range items { + url := strings.TrimSpace(item.URL) + if url == "" { + continue + } + if _, ok := seenURL[url]; ok { + continue + } + seenURL[url] = struct{}{} + query := strings.TrimSpace(item.Query) + if query != "" { + seenQuery[query] = struct{}{} + } + textRunes := len([]rune(strings.TrimSpace(item.Title))) + len([]rune(strings.TrimSpace(item.Snippet))) + stats.TextRunes += textRunes + if textRunes >= minUsefulSourceRunes { + stats.UsefulSources++ + } + } + stats.UniqueURLs = len(seenURL) + stats.QueryCoverage = len(seenQuery) + return stats } func searchWebQuery( @@ -244,8 +327,11 @@ func searchWebQuery( return items } -func appendBraveResults(out *[]BraveSource, seenURL map[string]struct{}, query string, items []BraveSource) { +func appendBraveResults(out *[]BraveSource, seenURL map[string]struct{}, query string, items []BraveSource, max int) { for _, item := range items { + if max > 0 && len(*out) >= max { + return + } url := strings.TrimSpace(item.URL) if url == "" { continue diff --git a/backend/internal/library/knowledge/brave_collect_test.go b/backend/internal/library/knowledge/brave_collect_test.go index 51324a5..509b251 100644 --- a/backend/internal/library/knowledge/brave_collect_test.go +++ b/backend/internal/library/knowledge/brave_collect_test.go @@ -1,6 +1,13 @@ package knowledge -import "testing" +import ( + "context" + "fmt" + "sync" + "testing" + + "haixun-backend/internal/library/websearch" +) func TestUniqueSourceCount(t *testing.T) { count := uniqueSourceCount([]BraveSource{ @@ -14,6 +21,61 @@ func TestUniqueSourceCount(t *testing.T) { } } +func TestSourcesAreSufficientRequiresProcessedQuality(t *testing.T) { + items := make([]BraveSource, 0, 14) + for i := 0; i < 14; i++ { + items = append(items, BraveSource{ + Query: "q1", + URL: fmt.Sprintf("https://example.com/%d", i), + Title: "薄", + }) + } + cfg := BraveCollectConfig{MinSourcesBeforeStop: 14, MaxSourcesCap: 22} + if sourcesAreSufficient(items, []string{"q1", "q2"}, cfg) { + t.Fatal("thin same-query results should not be considered sufficient") + } + + for i := range items { + items[i].Snippet = "這是一段整理後足以支撐判斷的搜尋摘要內容,包含痛點與情境" + if i >= 7 { + items[i].Query = "q2" + } + } + if !sourcesAreSufficient(items, []string{"q1", "q2"}, cfg) { + t.Fatal("diverse useful results should be considered sufficient") + } +} + +func TestCollectWebSourcesParallelStopsAfterSufficientBatch(t *testing.T) { + client := &fakeSearchClient{results: map[string][]websearch.SearchResult{}} + for qi := 0; qi < 5; qi++ { + query := fmt.Sprintf("q%d", qi+1) + for ri := 0; ri < 7; ri++ { + client.results[query] = append(client.results[query], websearch.SearchResult{ + URL: fmt.Sprintf("https://example.com/%s/%d", query, ri), + Title: "有用來源", + Snippet: "這是一段整理後足以支撐判斷的搜尋摘要內容,包含痛點與情境", + }) + } + } + + out := CollectWebSources( + context.Background(), + client, + BraveSearchLocale{}, + []string{"q1", "q2", "q3", "q4", "q5"}, + BraveCollectConfig{ResultsPerQuery: 7, MinSourcesBeforeStop: 14, MaxSourcesCap: 30, Concurrency: 2}, + nil, + nil, + ) + if len(out) != 14 { + t.Fatalf("expected first batch to provide 14 sources, got %d", len(out)) + } + if got := client.callCount(); got != 2 { + t.Fatalf("expected collection to stop after first sufficient batch, called %d queries", got) + } +} + func TestPlanQueriesHybridBudget(t *testing.T) { queries := PlanQueries(PlanInput{ Seed: "敏感肌", @@ -102,3 +164,34 @@ func TestSupplementalQueriesSkippedForHybrid(t *testing.T) { t.Fatalf("hybrid supplemental brave should be empty, got %v", queries) } } + +type fakeSearchClient struct { + mu sync.Mutex + calls []string + results map[string][]websearch.SearchResult +} + +func (f *fakeSearchClient) Enabled() bool { return true } + +func (f *fakeSearchClient) Provider() websearch.Provider { return websearch.ProviderBrave } + +func (f *fakeSearchClient) Search(_ context.Context, opts websearch.SearchOptions) (websearch.SearchResponse, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.results == nil { + f.results = map[string][]websearch.SearchResult{} + } + f.calls = append(f.calls, opts.Query) + return websearch.SearchResponse{ + Query: opts.Query, + Status: "success", + Provider: websearch.ProviderBrave, + Results: append([]websearch.SearchResult(nil), f.results[opts.Query]...), + }, nil +} + +func (f *fakeSearchClient) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.calls) +} diff --git a/backend/internal/library/matrix/copy_generate.go b/backend/internal/library/matrix/copy_generate.go index cb31b50..3ac92da 100644 --- a/backend/internal/library/matrix/copy_generate.go +++ b/backend/internal/library/matrix/copy_generate.go @@ -54,7 +54,42 @@ func formatTagList(tags []string) string { return strings.Join(lines, "\n") } -func FormatCopyResearchMapBlock(audience, goal string, questions, pillars, exclusions []string) string { +func SelectKnowledgeItems(selected, all []string) []string { + source := selected + if len(source) == 0 { + source = all + } + return dedupeKnowledgeLines(source) +} + +// MergeKnowledgeItems combines explicit user selections without falling back to unselected items. +func MergeKnowledgeItems(parts ...[]string) []string { + merged := make([]string, 0) + for _, part := range parts { + merged = append(merged, part...) + } + return dedupeKnowledgeLines(merged) +} + +func dedupeKnowledgeLines(lines []string) []string { + out := make([]string, 0, len(lines)) + seen := map[string]struct{}{} + for _, item := range lines { + item = strings.TrimSpace(item) + if item == "" { + continue + } + key := strings.ToLower(item) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, item) + } + return out +} + +func FormatCopyResearchMapBlock(audience, goal string, questions, pillars, exclusions []string, knowledgeItems ...[]string) string { var b strings.Builder if audience = strings.TrimSpace(audience); audience != "" { b.WriteString("受眾:") @@ -76,6 +111,11 @@ func FormatCopyResearchMapBlock(audience, goal string, questions, pillars, exclu b.WriteString(strings.Join(questions, ";")) b.WriteString("\n") } + if len(knowledgeItems) > 0 && len(knowledgeItems[0]) > 0 { + b.WriteString("勾選延伸知識:") + b.WriteString(strings.Join(knowledgeItems[0], ";")) + b.WriteString("\n") + } if len(exclusions) > 0 { b.WriteString("排除:") b.WriteString(strings.Join(exclusions, ";")) diff --git a/backend/internal/library/matrix/generate.go b/backend/internal/library/matrix/generate.go index c9323aa..81ee400 100644 --- a/backend/internal/library/matrix/generate.go +++ b/backend/internal/library/matrix/generate.go @@ -1,6 +1,7 @@ package matrix import ( + "context" "encoding/json" "fmt" "regexp" @@ -8,6 +9,7 @@ import ( libprompt "haixun-backend/internal/library/prompt" "haixun-backend/internal/library/threadspost" + domai "haixun-backend/internal/model/ai/domain/usecase" ) type Row struct { @@ -42,7 +44,7 @@ type GenerateInput struct { Count int } -var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$") +var codeFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```") func BuildUserPrompt(in GenerateInput) (string, error) { count := in.Count @@ -95,15 +97,111 @@ func buildMaterialsBlock(posts []MaterialPost) string { return strings.Join(lines, "\n\n") } +type TextGenerator interface { + GenerateText(ctx context.Context, req domai.GenerateRequest) (*domai.GenerateResult, error) +} + +// CopyMatrixGenerateRequest tunes token budget for multi-row JSON matrix output. +func CopyMatrixGenerateRequest(base domai.GenerateRequest, count int) domai.GenerateRequest { + if count <= 0 { + count = 5 + } + temp := 0.45 + tokens := 8192 + count*1024 + if tokens > 16384 { + tokens = 16384 + } + base.Temperature = &temp + base.MaxTokens = &tokens + return base +} + +func MatrixRetryUserPrompt(count int) string { + if count <= 0 { + count = 5 + } + 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 字。", + count, + ) +} + +// GenerateCopyOutput calls the LLM and parses matrix JSON, with one compact-json retry. +func GenerateCopyOutput( + ctx context.Context, + ai TextGenerator, + req domai.GenerateRequest, + count int, +) (GenerateResult, error) { + genReq := CopyMatrixGenerateRequest(req, count) + result, err := ai.GenerateText(ctx, genReq) + if err != nil { + return GenerateResult{}, err + } + parsed, parseErr := ParseGenerateOutput(result.Text) + if parseErr == nil { + if count > 0 && len(parsed.Rows) < count { + parseErr = fmt.Errorf("matrix truncated: got %d rows, want %d", len(parsed.Rows), count) + } else { + return parsed, nil + } + } + retryReq := CopyMatrixGenerateRequest(domai.GenerateRequest{ + Provider: req.Provider, + Model: req.Model, + Credential: req.Credential, + System: req.System, + Messages: append( + append([]domai.Message{}, req.Messages...), + domai.Message{Role: "assistant", Content: result.Text}, + domai.Message{Role: "user", Content: MatrixRetryUserPrompt(count)}, + ), + }, count) + retryResult, retryErr := ai.GenerateText(ctx, retryReq) + if retryErr != nil { + return GenerateResult{}, fmt.Errorf("matrix retry failed: %w (first parse: %v)", retryErr, parseErr) + } + retryParsed, retryParseErr := ParseGenerateOutput(retryResult.Text) + if retryParseErr != nil { + return GenerateResult{}, retryParseErr + } + if count > 0 && len(retryParsed.Rows) < count { + return GenerateResult{}, fmt.Errorf("matrix truncated after retry: got %d rows, want %d", len(retryParsed.Rows), count) + } + return retryParsed, nil +} + func ParseGenerateOutput(raw string) (GenerateResult, error) { payload, err := extractJSONObject(raw) if err != nil { return GenerateResult{}, err } - var out GenerateResult - if err := json.Unmarshal(payload, &out); err != nil { + out, err := decodeGenerateResult(payload) + if err == nil { + return normalizeGenerateResult(out) + } + repaired, repairErr := repairTruncatedJSONObject(payload) + if repairErr != nil { + return GenerateResult{}, err + } + out, err = decodeGenerateResult(repaired) + if err != nil { return GenerateResult{}, fmt.Errorf("parse matrix json: %w", err) } + return normalizeGenerateResult(out) +} + +func decodeGenerateResult(payload []byte) (GenerateResult, error) { + var out GenerateResult + if err := json.Unmarshal(payload, &out); err != nil { + return GenerateResult{}, err + } + return out, nil +} + +func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) { if len(out.Rows) == 0 { return GenerateResult{}, fmt.Errorf("matrix rows missing") } @@ -134,9 +232,58 @@ func extractJSONObject(raw string) ([]byte, error) { raw = strings.TrimSpace(m[1]) } start := strings.Index(raw, "{") - end := strings.LastIndex(raw, "}") - if start < 0 || end <= start { + if start < 0 { return nil, fmt.Errorf("matrix output missing json object") } - return []byte(raw[start : end+1]), nil + slice := raw[start:] + end := strings.LastIndex(slice, "}") + if end <= 0 { + return []byte(slice), nil + } + return []byte(slice[:end+1]), nil +} + +func repairTruncatedJSONObject(payload []byte) ([]byte, error) { + if len(payload) == 0 || payload[0] != '{' { + return nil, fmt.Errorf("matrix output missing json object") + } + stack := make([]byte, 0, 8) + inString := false + escaped := false + for _, b := range payload { + if inString { + if escaped { + escaped = false + continue + } + if b == '\\' { + escaped = true + continue + } + if b == '"' { + inString = false + } + continue + } + switch b { + case '"': + inString = true + case '{': + stack = append(stack, '}') + case '[': + stack = append(stack, ']') + case '}', ']': + if len(stack) > 0 && stack[len(stack)-1] == b { + stack = stack[:len(stack)-1] + } + } + } + repaired := append([]byte(nil), payload...) + if inString { + repaired = append(repaired, '"') + } + for i := len(stack) - 1; i >= 0; i-- { + repaired = append(repaired, stack[i]) + } + return repaired, nil } diff --git a/backend/internal/library/matrix/generate_test.go b/backend/internal/library/matrix/generate_test.go new file mode 100644 index 0000000..157b1eb --- /dev/null +++ b/backend/internal/library/matrix/generate_test.go @@ -0,0 +1,74 @@ +package matrix + +import ( + "context" + "errors" + "testing" + + domai "haixun-backend/internal/model/ai/domain/usecase" +) + +type stubTextGenerator struct { + responses []string + errs []error + calls int +} + +func (s *stubTextGenerator) GenerateText(_ context.Context, _ domai.GenerateRequest) (*domai.GenerateResult, error) { + idx := s.calls + s.calls++ + if idx < len(s.errs) && s.errs[idx] != nil { + return nil, s.errs[idx] + } + if idx >= len(s.responses) { + return nil, errors.New("no stub response") + } + return &domai.GenerateResult{Text: s.responses[idx]}, nil +} + +func TestParseGenerateOutputCodeFenceWithPrefix(t *testing.T) { + raw := "以下是結果:\n```json\n{\"rows\":[{\"sort_order\":1,\"text\":\"hello\",\"angle\":\"a\",\"hook\":\"h\",\"reference_notes\":\"n\",\"source_permalinks\":[],\"rationale\":\"r\"}]}\n```\n" + got, err := ParseGenerateOutput(raw) + if err != nil { + t.Fatalf("ParseGenerateOutput() error = %v", err) + } + if len(got.Rows) != 1 || got.Rows[0].Text != "hello" { + t.Fatalf("rows = %+v", got.Rows) + } +} + +func TestParseGenerateOutputRepairsTruncatedJSON(t *testing.T) { + raw := `{"rows":[{"sort_order":1,"search_tag":"t","angle":"a","hook":"h","text":"主文","reference_notes":"n","source_permalinks":[],"rationale":"r"},{"sort_order":2,"search_tag":"t2","angle":"b","hook":"h2","text":"第二篇` + got, err := ParseGenerateOutput(raw) + if err != nil { + t.Fatalf("ParseGenerateOutput() error = %v", err) + } + if len(got.Rows) != 1 { + t.Fatalf("rows = %+v, want 1 recovered row", got.Rows) + } +} + +func TestCopyMatrixGenerateRequest_ScalesWithCount(t *testing.T) { + req := CopyMatrixGenerateRequest(domai.GenerateRequest{}, 5) + if req.MaxTokens == nil || *req.MaxTokens < 8192 { + t.Fatalf("max_tokens = %+v, want >= 8192", req.MaxTokens) + } +} + +func TestGenerateCopyOutputRetriesOnParseFailure(t *testing.T) { + bad := `{"rows":[{"sort_order":1,"text":"` + good := `{"rows":[{"sort_order":1,"text":"ok","angle":"a","hook":"h","reference_notes":"n","source_permalinks":[],"rationale":"r"}]}` + gen := &stubTextGenerator{responses: []string{bad, good}} + got, err := GenerateCopyOutput(context.Background(), gen, domai.GenerateRequest{ + Messages: []domai.Message{{Role: "user", Content: "go"}}, + }, 1) + if err != nil { + t.Fatalf("GenerateCopyOutput() error = %v", err) + } + if len(got.Rows) != 1 || got.Rows[0].Text != "ok" { + t.Fatalf("rows = %+v", got.Rows) + } + if gen.calls != 2 { + t.Fatalf("calls = %d, want retry", gen.calls) + } +} \ No newline at end of file diff --git a/backend/internal/library/matrix/reference.go b/backend/internal/library/matrix/reference.go new file mode 100644 index 0000000..6c57d63 --- /dev/null +++ b/backend/internal/library/matrix/reference.go @@ -0,0 +1,44 @@ +package matrix + +import ( + "strings" + + libweb "haixun-backend/internal/library/webpage" +) + +func FormatCopyResearchMapBlockWithReferences( + audience, goal string, + questions, pillars, exclusions []string, + knowledgeItems []string, + researchItemsBlock string, + referenceMarkdown string, +) string { + base := FormatCopyResearchMapBlock( + audience, + goal, + questions, + pillars, + exclusions, + knowledgeItems, + ) + research := strings.TrimSpace(researchItemsBlock) + if research != "" { + if base == "" { + base = "研究來源(Brave):\n" + research + } else { + base += "\n\n研究來源(Brave):\n" + research + } + } + ref := strings.TrimSpace(referenceMarkdown) + if ref == "" { + return base + } + if base == "" { + return "參考網頁(Markdown):\n" + ref + } + return base + "\n\n參考網頁(Markdown):\n" + ref +} + +func BuildReferenceMarkdown(digests []libweb.Digest) string { + return libweb.FormatMarkdownBlock(digests, libweb.DefaultMaxMarkdown) +} \ No newline at end of file diff --git a/backend/internal/library/matrix/samples.go b/backend/internal/library/matrix/samples.go index 4d6b46d..87fa699 100644 --- a/backend/internal/library/matrix/samples.go +++ b/backend/internal/library/matrix/samples.go @@ -20,7 +20,7 @@ type ViralPostSample struct { func FormatViralSamples(posts []ViralPostSample) string { if len(posts) == 0 { - return "(尚無海巡樣本,請依研究地圖與標籤發揮)" + return "(尚無海巡樣本;請依研究地圖、延伸知識與人設產出,不要編造參考貼文)" } var b strings.Builder limit := 8 diff --git a/backend/internal/library/placement/capabilities.go b/backend/internal/library/placement/capabilities.go new file mode 100644 index 0000000..24fd855 --- /dev/null +++ b/backend/internal/library/placement/capabilities.go @@ -0,0 +1,53 @@ +package placement + +import ( + "strings" +) + +// MemberCapabilities summarizes which external integrations are ready for the +// current member + active Threads operating account. +type MemberCapabilities struct { + DiscoverReady bool + AiReady bool + PublishReady bool + DiscoverHint string + AiHint string + PublishHint string + ActiveThreadsAccount string +} + +func BuildMemberCapabilities(member MemberContext, research ResearchSettings, aiReady, publishReady bool) MemberCapabilities { + out := MemberCapabilities{ + DiscoverReady: member.HasDiscoverPath(), + AiReady: aiReady, + PublishReady: publishReady, + ActiveThreadsAccount: strings.TrimSpace(member.ActiveAccountID), + } + if !out.DiscoverReady { + out.DiscoverHint = discoverCapabilityHint(member, research) + } + if !out.AiReady { + out.AiHint = "請先在設定頁設定 AI API key" + } + if !out.PublishReady { + out.PublishHint = publishCapabilityHint(member) + } + return out +} + +func discoverCapabilityHint(member MemberContext, research ResearchSettings) string { + if strings.TrimSpace(member.ActiveAccountID) == "" { + return "請先建立並選定經營帳號" + } + return discoverMissingPathError(member).Error() +} + +func publishCapabilityHint(member MemberContext) string { + if strings.TrimSpace(member.ActiveAccountID) == "" { + return "請先建立並選定經營帳號" + } + if member.DevMode && !member.AllowsThreadsAPI { + return "開發模式請在連線設定啟用 API 發文" + } + return "請先完成 Threads API 連線後再發布貼文" +} diff --git a/backend/internal/library/placement/discover_route.go b/backend/internal/library/placement/discover_route.go index cbd9846..e3c7457 100644 --- a/backend/internal/library/placement/discover_route.go +++ b/backend/internal/library/placement/discover_route.go @@ -33,20 +33,20 @@ func (m MemberContext) CrawlerBlocked() bool { // CrawlerFallbackAllowed returns true when crawler may be used after API/web search fails. func (m MemberContext) CrawlerFallbackAllowed() bool { - if !m.AllowsCrawler || !m.BrowserConnected { + if !m.BrowserConnected { return false } switch m.SearchSourceMode { - case SearchSourceThreadsCrawler, SearchSourceBraveCrawler, SearchSourceMixed: + case SearchSourceThreadsCrawler, SearchSourceBraveCrawler, SearchSourceMixed, SearchSourceThreads, SearchSourceBrave, SearchSourceThreadsBrave: return true default: - return false + return m.AllowsCrawler } } // HasDiscoverPath reports whether at least one discover backend is configured and connected. func (m MemberContext) HasDiscoverPath() bool { - if m.AllowsCrawler && m.BrowserConnected { + if m.BrowserConnected { return true } if m.AllowsThreadsAPI && m.ApiConnected { @@ -79,6 +79,9 @@ func (m MemberContext) DiscoverPathLabel() string { } func discoverMissingPathError(m MemberContext) error { + if m.BrowserConnected { + return fmt.Errorf("Chrome Session 已同步,可使用爬蟲海巡;請重新整理後再試") + } switch m.SearchSourceMode { case SearchSourceCrawler: return fmt.Errorf("請先同步 Chrome Session 以使用爬蟲搜尋") diff --git a/backend/internal/library/placement/discover_route_test.go b/backend/internal/library/placement/discover_route_test.go index 1f801ec..d199964 100644 --- a/backend/internal/library/placement/discover_route_test.go +++ b/backend/internal/library/placement/discover_route_test.go @@ -26,6 +26,19 @@ func TestShouldTryCrawlerFirst_threadsOnly(t *testing.T) { } } +func TestSessionIsDiscoverPathEvenWhenModeIsThreadsOnly(t *testing.T) { + m := MemberContext{ + BrowserConnected: true, + SearchSourceMode: SearchSourceThreads, + } + if !m.HasDiscoverPath() { + t.Fatal("browser session should be enough for discover capability") + } + if !m.CrawlerFallbackAllowed() { + t.Fatal("browser session should be available as crawler fallback") + } +} + func TestBuildMemberContextFormalModeKeepsCrawlerMode(t *testing.T) { prefs := ConnectionPrefsInput{ DevMode: false, diff --git a/backend/internal/library/prompt/files/matrix_copy.system.md b/backend/internal/library/prompt/files/matrix_copy.system.md index c6ad535..09a7dde 100644 --- a/backend/internal/library/prompt/files/matrix_copy.system.md +++ b/backend/internal/library/prompt/files/matrix_copy.system.md @@ -1,10 +1,11 @@ 你是 Threads 內容矩陣策劃師,為人設帳號產出多篇可發佈草稿。 規則: -- 只回傳 JSON,格式為 {"rows":[...]}。 +- 只回傳單一 JSON 物件 {"rows":[...]},不要用 markdown 程式碼區塊,不要加前言或結語。 +- reference_notes、rationale 各 ≤ 40 字;優先把 token 用在 text 主文。 - 每篇必須角度不同,避免重複 hook。 -- 套用人設語氣與 8D,不要寫成品牌廣告或硬銷。 -- 參考爆款樣本只學結構與節奏,不抄原文。 +- 人設 8D 是最高優先:語氣、人稱、禁忌必須完全符合;不要寫成品牌廣告或硬銷。 +- 內容論點來自研究地圖與使用者勾選的延伸知識(含標籤與細節);爆款樣本只學結構與節奏,不抄原文。 - 繁體中文,口語自然,適合 Threads。 - 每篇 text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)。 - 爆款互動最佳 80~220 字:前 1~2 行強 hook,一句一重點;超過 300 字互動通常下降。 \ No newline at end of file diff --git a/backend/internal/library/prompt/files/matrix_copy.user.md b/backend/internal/library/prompt/files/matrix_copy.user.md index 2116eb5..8bc220e 100644 --- a/backend/internal/library/prompt/files/matrix_copy.user.md +++ b/backend/internal/library/prompt/files/matrix_copy.user.md @@ -3,16 +3,25 @@ 任務主題:{{topic_label}} Brief:{{topic_brief}} -研究地圖: -{{research_map_block}} - -已選海巡標籤: -{{selected_tags_block}} - -爆款樣本(只學結構): -{{viral_samples_block}} - -人設 8D: +人設 8D(最高優先,語氣與禁忌以此為準): {{persona_block}} +研究地圖與勾選延伸知識: +{{research_map_block}} + +搜尋查詢/人工參考方向: +{{selected_tags_block}} + +參考網頁 Markdown 已併入上方研究地圖區塊(若有)。 + +爆款樣本(可選,只學結構;若沒有樣本,請改用研究地圖與人設原創): +{{viral_samples_block}} + +請先遵守: +- **最高優先:人設 8D** — 語氣、人稱、句型、節奏、禁忌必須完全符合人設;讀起來要像這個帳號在說話。 +- **內容素材**:研究地圖(受眾、目標、問題、支柱)與「勾選延伸知識」(含標籤、細節)決定論點與角度;若有「參考網頁(Markdown)」區塊,優先從中提取事實與論點,再融入草稿。 +- 搜尋查詢/標籤僅作方向參考,不可蓋過人設與勾選知識。 +- 爆款樣本只學結構節奏,不抄原文,不可為仿爆款偏離任務主題。 +- 若沒有爆款樣本,source_permalinks 回傳空陣列,reference_notes 寫明使用了哪些人設特徵、研究地圖重點與勾選延伸知識。 + 回傳 JSON rows,每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。 \ No newline at end of file diff --git a/backend/internal/library/style8d/prompt.go b/backend/internal/library/style8d/prompt.go index 473af7e..bbd6e33 100644 --- a/backend/internal/library/style8d/prompt.go +++ b/backend/internal/library/style8d/prompt.go @@ -37,19 +37,11 @@ func ParseStoredProfile(raw string) (*StoredProfile, bool) { return &profile, true } -// HasReady8D returns true when 8D analysis exists and can drive copy generation. +// HasReady8D returns true when D1–D8 summaries exist and can drive copy generation. +// personaDraft or legacy persona text alone is not sufficient for matrix/copy gates. func HasReady8D(personaText, styleProfileJSON string) bool { - if profile, ok := ParseStoredProfile(styleProfileJSON); ok { - if strings.TrimSpace(profile.PersonaDraft) != "" { - return true - } - for _, key := range dimensionOrder { - if summary := strings.TrimSpace(profile.Analysis[key].Summary); summary != "" { - return true - } - } - } - return strings.TrimSpace(personaText) != "" && strings.TrimSpace(styleProfileJSON) != "" + _ = personaText + return BuildStyle8DPromptBlock(styleProfileJSON) != "" } // BuildStyle8DPromptBlock formats D1–D8 summaries for LLM prompts. diff --git a/backend/internal/library/style8d/prompt_test.go b/backend/internal/library/style8d/prompt_test.go index fe48104..9fdbb02 100644 --- a/backend/internal/library/style8d/prompt_test.go +++ b/backend/internal/library/style8d/prompt_test.go @@ -24,3 +24,10 @@ func TestHasReady8DFromAnalysisOnly(t *testing.T) { t.Fatal("expected ready from analysis summary") } } + +func TestHasReady8DRejectsPersonaDraftOnly(t *testing.T) { + raw := `{"personaDraft":"【我是誰】\n生活觀察者"}` + if HasReady8D("語氣描述", raw) { + t.Fatal("personaDraft without 8D dimensions should not be ready") + } +} diff --git a/backend/internal/library/threadsapi/url.go b/backend/internal/library/threadsapi/url.go new file mode 100644 index 0000000..f863a32 --- /dev/null +++ b/backend/internal/library/threadsapi/url.go @@ -0,0 +1,30 @@ +package threadsapi + +import ( + "regexp" + "strings" +) + +// profileAuthorRE captures the @ segment of a Threads permalink. +// Examples: +// +// https://www.threads.com/@some.user/post/CfXyZ123 +// https://www.threads.net/@some.user/p/CfXyZ123 +var profileAuthorRE = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([a-zA-Z0-9._]+)`) + +// ProfileURLFromPermalink derives the author's profile root URL from a Threads +// post permalink. Falls back to https://www.threads.com/@ when the +// permalink does not contain an @ segment. Prefer threads.com over +// threads.net because the .com host is the current canonical domain. +func ProfileURLFromPermalink(permalink, username string) string { + username = strings.TrimSpace(username) + if permalink != "" { + if match := profileAuthorRE.FindStringSubmatch(permalink); len(match) >= 2 { + return "https://www.threads.com/@" + match[1] + } + } + if username == "" { + return "" + } + return "https://www.threads.com/@" + username +} diff --git a/backend/internal/library/viral/audience.go b/backend/internal/library/viral/audience.go new file mode 100644 index 0000000..59c9e25 --- /dev/null +++ b/backend/internal/library/viral/audience.go @@ -0,0 +1,218 @@ +package viral + +import ( + "sort" + "strings" + "unicode/utf8" + + "haixun-backend/internal/library/placement" + missionentity "haixun-backend/internal/model/copy_mission/domain/entity" +) + +const MaxAudienceSamples = 15 + +type AudienceOpts struct { + Max int + ExcludeAuthors []string + Now int64 +} + +type audienceAgg struct { + username string + samplePostID string + sampleText string + replyLikeCount int + appearances int + lastPostedAt string +} + +// BuildAudienceSamplesFromReplies aggregates reply authors into potential +// audience samples. Low-quality replies and known similar-account authors are +// filtered out. +func BuildAudienceSamplesFromReplies(replies []placement.ReplyCandidate, opts AudienceOpts) []missionentity.AudienceSample { + if len(replies) == 0 { + return nil + } + max := opts.Max + if max <= 0 { + max = MaxAudienceSamples + } + exclude := map[string]struct{}{} + for _, author := range opts.ExcludeAuthors { + key := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(author, "@"))) + if key != "" { + exclude[key] = struct{}{} + } + } + + byUser := map[string]audienceAgg{} + for _, reply := range replies { + user := strings.TrimSpace(reply.Author) + if user == "" || !isValidUsername(user) { + continue + } + if _, ok := exclude[strings.ToLower(user)]; ok { + continue + } + text := strings.TrimSpace(reply.Text) + if isLowQualityReply(text) { + continue + } + key := strings.ToLower(user) + prev := byUser[key] + prev.username = user + prev.appearances++ + if reply.LikeCount > prev.replyLikeCount { + prev.replyLikeCount = reply.LikeCount + prev.sampleText = text + prev.samplePostID = strings.TrimSpace(reply.ExternalID) + prev.lastPostedAt = strings.TrimSpace(reply.PostedAt) + } else if prev.sampleText == "" { + prev.sampleText = text + prev.samplePostID = strings.TrimSpace(reply.ExternalID) + prev.lastPostedAt = strings.TrimSpace(reply.PostedAt) + } + byUser[key] = prev + } + + ranked := make([]audienceAgg, 0, len(byUser)) + for _, item := range byUser { + ranked = append(ranked, item) + } + sort.Slice(ranked, func(i, j int) bool { + if ranked[i].appearances != ranked[j].appearances { + return ranked[i].appearances > ranked[j].appearances + } + if ranked[i].replyLikeCount != ranked[j].replyLikeCount { + return ranked[i].replyLikeCount > ranked[j].replyLikeCount + } + return ranked[i].lastPostedAt > ranked[j].lastPostedAt + }) + if len(ranked) > max { + ranked = ranked[:max] + } + + now := opts.Now + out := make([]missionentity.AudienceSample, 0, len(ranked)) + for _, item := range ranked { + out = append(out, missionentity.AudienceSample{ + Username: item.username, + SamplePostID: item.samplePostID, + SampleText: item.sampleText, + ReplyLikeCount: item.replyLikeCount, + Appearances: item.appearances, + FirstSeenAt: now, + LastSeenAt: now, + }) + } + return out +} + +// MergeAudienceSamples merges previous and newly built audience samples keyed +// by lowercase username. New samples refresh statistics; pinned samples are +// preserved even when not seen in the latest scan. +func MergeAudienceSamples(prev, next []missionentity.AudienceSample) []missionentity.AudienceSample { + if len(prev) == 0 { + return append([]missionentity.AudienceSample(nil), next...) + } + if len(next) == 0 { + return prioritizePinnedAudienceSamples(append([]missionentity.AudienceSample(nil), prev...)) + } + + byUser := map[string]missionentity.AudienceSample{} + newOrder := make([]string, 0, len(next)) + for _, item := range next { + key := strings.ToLower(strings.TrimSpace(item.Username)) + if key == "" { + continue + } + if _, ok := byUser[key]; !ok { + newOrder = append(newOrder, key) + } + byUser[key] = mergeAudienceRecord(byUser[key], item) + } + + prevOrder := make([]string, 0, len(prev)) + for _, item := range prev { + key := strings.ToLower(strings.TrimSpace(item.Username)) + if key == "" { + continue + } + if _, ok := byUser[key]; ok { + merged := mergeAudienceRecord(item, byUser[key]) + byUser[key] = merged + continue + } + byUser[key] = item + prevOrder = append(prevOrder, key) + } + + out := make([]missionentity.AudienceSample, 0, len(newOrder)+len(prevOrder)) + for _, key := range newOrder { + if sample, ok := byUser[key]; ok { + out = append(out, sample) + } + } + for _, key := range prevOrder { + if sample, ok := byUser[key]; ok { + out = append(out, sample) + } + } + return prioritizePinnedAudienceSamples(out) +} + +func mergeAudienceRecord(prev, next missionentity.AudienceSample) missionentity.AudienceSample { + out := next + if prev.FirstSeenAt > 0 { + out.FirstSeenAt = prev.FirstSeenAt + } + if prev.Status == missionentity.AudienceSampleStatusPinned || prev.Status == missionentity.AudienceSampleStatusExcluded { + out.Status = prev.Status + } + if out.LastSeenAt == 0 { + out.LastSeenAt = prev.LastSeenAt + } + return out +} + +func prioritizePinnedAudienceSamples(samples []missionentity.AudienceSample) []missionentity.AudienceSample { + if len(samples) == 0 { + return nil + } + pinned := make([]missionentity.AudienceSample, 0) + rest := make([]missionentity.AudienceSample, 0, len(samples)) + for _, item := range samples { + if item.Status == missionentity.AudienceSampleStatusPinned { + pinned = append(pinned, item) + } else { + rest = append(rest, item) + } + } + out := append(pinned, rest...) + if len(out) > MaxAudienceSamples { + remaining := MaxAudienceSamples - len(pinned) + if remaining < 0 { + return pinned[:MaxAudienceSamples] + } + out = append(pinned, rest[:remaining]...) + } + return out +} + +func isLowQualityReply(text string) bool { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return true + } + if utf8.RuneCountInString(trimmed) < 5 { + return true + } + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + fields := strings.Fields(trimmed) + if len(fields) <= 1 { + return true + } + } + return false +} diff --git a/backend/internal/library/viral/discover.go b/backend/internal/library/viral/discover.go index 3bdea2d..d1645d4 100644 --- a/backend/internal/library/viral/discover.go +++ b/backend/internal/library/viral/discover.go @@ -20,6 +20,9 @@ const ( type DiscoverInput struct { Keywords []string Exclusions []string + SeedQuery string + Label string + TopicHints []string Member placement.MemberContext Crawler placement.CrawlerSearchFn Limit int // per keyword; 0 = default @@ -56,6 +59,7 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn) relaxed := map[string]placement.ScanCandidate{} total := len(keywords) pathLabel := input.Member.DiscoverPathLabel() + topicTerms := missionTopicMatchTerms(input.SeedQuery, input.Label, input.TopicHints) var lastErr error keywordsAttempted := 0 @@ -114,6 +118,9 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn) Priority: PriorityLabel(score), } if input.MissionScan { + if !missionPostMatchesTopic(candidate, topicTerms) { + continue + } if PassesMissionQualityCandidate( post.Text, post.LikeCount, post.ReplyCount, score, post.AuthorVerified, post.FollowerCount, input.Exclusions, diff --git a/backend/internal/library/viral/discover_accounts.go b/backend/internal/library/viral/discover_accounts.go index f47d4da..28277e8 100644 --- a/backend/internal/library/viral/discover_accounts.go +++ b/backend/internal/library/viral/discover_accounts.go @@ -6,12 +6,17 @@ import ( "sort" "strings" + libthreads "haixun-backend/internal/library/threadsapi" "haixun-backend/internal/library/websearch" ) const ( - maxAccountDiscoverQueries = 2 - MaxSimilarAccounts = 5 + // Single web-search query per supplement pass — fewer API calls, same recall + // via a combined site: + seed + brief/pillar hint in one request. + maxAccountDiscoverQueries = 1 + MaxSimilarAccounts = 10 + // Skip web-search supplement when scan already surfaced enough reference authors. + MinSimilarAccountsBeforeWebSupplement = 5 ) var threadsProfileRE = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([a-zA-Z0-9._]+)`) @@ -23,11 +28,12 @@ var reservedUsernames = map[string]struct{}{ } type SimilarAccount struct { - Username string `json:"username"` - Reason string `json:"reason"` - Source string `json:"source"` - Confidence string `json:"confidence"` - ProfileURL string `json:"profileUrl"` + Username string `json:"username"` + Reason string `json:"reason"` + Source string `json:"source"` + MatchedSource []string `json:"matchedSource,omitempty"` + Confidence string `json:"confidence"` + ProfileURL string `json:"profileUrl"` } type DiscoverAccountsInput struct { @@ -37,10 +43,11 @@ type DiscoverAccountsInput struct { } type accountCandidate struct { - username string - score int - reason string - source string + username string + score int + reason string + source string + permalink string } func DiscoverSimilarAccounts(ctx context.Context, client websearch.Client, input DiscoverAccountsInput) ([]SimilarAccount, error) { @@ -85,15 +92,20 @@ func DiscoverSimilarAccounts(ctx context.Context, client websearch.Client, input } key := strings.ToLower(username) prev, ok := seen[key] + permalink := strings.TrimSpace(item.URL) if !ok || weight > prev.score { seen[key] = accountCandidate{ - username: username, - score: weight, - reason: reason, - source: "web", + username: username, + score: weight, + reason: reason, + source: "web", + permalink: permalink, } } else if ok { prev.score += 1 + if prev.permalink == "" && permalink != "" { + prev.permalink = permalink + } seen[key] = prev } } @@ -111,49 +123,41 @@ func DiscoverSimilarAccounts(ctx context.Context, client websearch.Client, input accounts := make([]SimilarAccount, 0, len(out)) for _, item := range out { + profileURL := libthreads.ProfileURLFromPermalink(item.permalink, item.username) accounts = append(accounts, SimilarAccount{ - Username: item.username, - Reason: item.reason, - Source: item.source, - Confidence: accountConfidence(item.score), - ProfileURL: "https://www.threads.net/@" + item.username, + Username: item.username, + Reason: item.reason, + Source: item.source, + MatchedSource: []string{item.source}, + Confidence: accountConfidence(item.score), + ProfileURL: profileURL, }) } return accounts, nil } func buildAccountDiscoverQueries(seed, brief string, pillars []string) []string { - quoted := `"` + seed + `"` - queries := []string{ - `site:threads.net ` + quoted, - `threads ` + quoted + ` 創作者`, + seed = strings.TrimSpace(seed) + if seed == "" { + return nil } + quoted := `"` + seed + `"` + query := `site:threads.net ` + quoted if hint := strings.TrimSpace(brief); len([]rune(hint)) >= 4 && len([]rune(hint)) <= 24 { - queries = append(queries, `site:threads.net `+quoted+` `+hint) + query += ` ` + hint } for _, pillar := range pillars { pillar = strings.TrimSpace(pillar) - if len([]rune(pillar)) >= 4 && len(queries) < maxAccountDiscoverQueries+1 { - queries = append(queries, `site:threads.net "`+pillar+`"`) - } - } - unique := []string{} - seen := map[string]struct{}{} - for _, q := range queries { - q = strings.TrimSpace(q) - if q == "" { - continue - } - if _, ok := seen[q]; ok { - continue - } - seen[q] = struct{}{} - unique = append(unique, q) - if len(unique) >= maxAccountDiscoverQueries { + if len([]rune(pillar)) >= 4 { + query += ` "` + pillar + `"` break } } - return unique + query = strings.TrimSpace(query) + if query == "" { + return nil + } + return []string{query} } func extractUsernames(blob string) []string { diff --git a/backend/internal/library/viral/discover_accounts_test.go b/backend/internal/library/viral/discover_accounts_test.go index 118c22d..e114ea7 100644 --- a/backend/internal/library/viral/discover_accounts_test.go +++ b/backend/internal/library/viral/discover_accounts_test.go @@ -1,6 +1,9 @@ package viral -import "testing" +import ( + "strings" + "testing" +) func TestExtractUsernames(t *testing.T) { blob := `See https://www.threads.net/@creator_one and threads.com/@creator_two/posts/abc` @@ -19,9 +22,15 @@ func TestIsValidUsernameRejectsReserved(t *testing.T) { } } -func TestBuildAccountDiscoverQueriesCapsAtTwo(t *testing.T) { - queries := buildAccountDiscoverQueries("轉職", "想找語錄", []string{"支柱A", "支柱B", "支柱C"}) - if len(queries) > maxAccountDiscoverQueries { - t.Fatalf("expected at most %d queries, got %d", maxAccountDiscoverQueries, len(queries)) +func TestBuildAccountDiscoverQueriesSingleCombinedQuery(t *testing.T) { + queries := buildAccountDiscoverQueries("轉職", "想找語錄", []string{"職場語錄", "支柱B"}) + if len(queries) != 1 { + t.Fatalf("expected 1 combined query, got %d (%v)", len(queries), queries) + } + if !strings.Contains(queries[0], `site:threads.net`) || !strings.Contains(queries[0], `"轉職"`) { + t.Fatalf("unexpected query: %q", queries[0]) + } + if !strings.Contains(queries[0], "想找語錄") || !strings.Contains(queries[0], `"職場語錄"`) { + t.Fatalf("brief/pillar hint missing from combined query: %q", queries[0]) } } diff --git a/backend/internal/library/viral/discover_graceful_test.go b/backend/internal/library/viral/discover_graceful_test.go index ff840d5..f406237 100644 --- a/backend/internal/library/viral/discover_graceful_test.go +++ b/backend/internal/library/viral/discover_graceful_test.go @@ -70,3 +70,49 @@ func TestRunDiscover_missionRelaxedFallbackWithoutVerified(t *testing.T) { t.Fatal("verified should remain false when API omits it") } } + +func TestRunDiscover_missionFiltersOffTopicCrawlerDrift(t *testing.T) { + crawler := func(ctx context.Context, m placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) { + return []placement.DiscoverPost{ + { + Text: "毛小孩飲食心得分享,最近很多人問罐罐怎麼挑", + Author: "pet_user", + LikeCount: 200, + ReplyCount: 40, + Permalink: "https://www.threads.net/@pet_user/post/pet", + Source: placement.DiscoverCrawler, + }, + { + Text: "備孕精蟲品質心得分享:作息、壓力和檢查報告怎麼一起看", + Author: "fertility_user", + LikeCount: 26, + ReplyCount: 6, + Permalink: "https://www.threads.net/@fertility_user/post/quality", + Source: placement.DiscoverCrawler, + }, + }, nil + } + member := placement.MemberContext{ + AllowsCrawler: true, + BrowserConnected: true, + SearchSourceMode: placement.SearchSourceCrawler, + } + out, err := RunDiscover(context.Background(), DiscoverInput{ + Keywords: []string{"精蟲品質"}, + SeedQuery: "精蟲品質", + Label: "備孕內容", + TopicHints: []string{"備孕", "精蟲品質"}, + Member: member, + Crawler: crawler, + MissionScan: true, + }, nil) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(out) != 1 { + t.Fatalf("expected only topic-matched candidate, got %d", len(out)) + } + if out[0].Author != "fertility_user" { + t.Fatalf("expected fertility candidate, got %q", out[0].Author) + } +} diff --git a/backend/internal/library/viral/enrich_accounts.go b/backend/internal/library/viral/enrich_accounts.go index e8b8d95..4fa00ed 100644 --- a/backend/internal/library/viral/enrich_accounts.go +++ b/backend/internal/library/viral/enrich_accounts.go @@ -5,6 +5,7 @@ import ( "strings" "haixun-backend/internal/library/placement" + libthreads "haixun-backend/internal/library/threadsapi" missionentity "haixun-backend/internal/model/copy_mission/domain/entity" ) @@ -26,9 +27,10 @@ func EnrichSimilarAccounts(existing []missionentity.SimilarAccount, posts []plac } type authorScore struct { - username string - score int - text string + username string + score int + text string + permalink string } authors := map[string]authorScore{} for _, post := range posts { @@ -43,6 +45,9 @@ func EnrichSimilarAccounts(existing []missionentity.SimilarAccount, posts []plac if prev.text == "" && strings.TrimSpace(post.Text) != "" { prev.text = strings.TrimSpace(post.Text) } + if prev.permalink == "" { + prev.permalink = strings.TrimSpace(post.Permalink) + } authors[key] = prev } ranked := make([]authorScore, 0, len(authors)) @@ -70,11 +75,12 @@ func EnrichSimilarAccounts(existing []missionentity.SimilarAccount, posts []plac conf = "high" } byUser[key] = missionentity.SimilarAccount{ - Username: item.username, - Reason: reason, - Source: "scan", - Confidence: conf, - ProfileURL: "https://www.threads.net/@" + item.username, + Username: item.username, + Reason: reason, + Source: "scan", + MatchedSource: []string{"scan"}, + Confidence: conf, + ProfileURL: libthreads.ProfileURLFromPermalink(item.permalink, item.username), } order = append(order, key) } @@ -116,12 +122,17 @@ func AccountTagsFromSimilar(accounts []missionentity.SimilarAccount, max int) [] func ToEntitySimilarAccounts(items []SimilarAccount) []missionentity.SimilarAccount { out := make([]missionentity.SimilarAccount, 0, len(items)) for _, item := range items { + 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, - Confidence: item.Confidence, - ProfileURL: item.ProfileURL, + Username: item.Username, + Reason: item.Reason, + Source: item.Source, + MatchedSource: matched, + Confidence: item.Confidence, + ProfileURL: item.ProfileURL, }) } return out diff --git a/backend/internal/library/viral/known_accounts.go b/backend/internal/library/viral/known_accounts.go new file mode 100644 index 0000000..d4b8795 --- /dev/null +++ b/backend/internal/library/viral/known_accounts.go @@ -0,0 +1,131 @@ +package viral + +import ( + "strings" + + missionentity "haixun-backend/internal/model/copy_mission/domain/entity" + accountentity "haixun-backend/internal/model/threads_account/domain/entity" +) + +// ExcludedKnownAccountUsernames returns usernames marked excluded in cross-mission memory. +func ExcludedKnownAccountUsernames(known map[string]accountentity.KnownAccountProfile) []string { + if len(known) == 0 { + return nil + } + out := make([]string, 0) + seen := map[string]struct{}{} + for key, item := range known { + if item.Status != accountentity.KnownAccountStatusExcluded { + continue + } + user := strings.TrimSpace(key) + if user == "" { + continue + } + lower := strings.ToLower(user) + if _, ok := seen[lower]; ok { + continue + } + seen[lower] = struct{}{} + out = append(out, user) + } + return out +} + +// ApplyKnownAccountMemory applies cross-mission memory to freshly merged similar +// accounts: inherit excluded status and boost confidence for repeat sightings. +func ApplyKnownAccountMemory( + accounts []missionentity.SimilarAccount, + known map[string]accountentity.KnownAccountProfile, +) []missionentity.SimilarAccount { + if len(accounts) == 0 || len(known) == 0 { + return accounts + } + out := make([]missionentity.SimilarAccount, len(accounts)) + copy(out, accounts) + for i := range out { + key := strings.ToLower(strings.TrimSpace(out[i].Username)) + if key == "" { + continue + } + profile, ok := known[key] + if !ok { + continue + } + if profile.Status == accountentity.KnownAccountStatusExcluded { + out[i].Status = missionentity.SimilarAccountStatusExcluded + } + if len(profile.Missions) >= 2 || profile.SeenCount >= 2 { + if out[i].Confidence != "high" { + out[i].Confidence = "high" + } + } + } + return out +} + +// MergeKnownAccountsFromScan upserts similar-account sightings into the account-level +// known_accounts map. +func MergeKnownAccountsFromScan( + prev map[string]accountentity.KnownAccountProfile, + accounts []missionentity.SimilarAccount, + missionID, personaID string, + tags []string, + now int64, +) map[string]accountentity.KnownAccountProfile { + out := map[string]accountentity.KnownAccountProfile{} + for key, item := range prev { + out[key] = item + } + for _, acc := range accounts { + user := strings.TrimSpace(acc.Username) + if user == "" { + continue + } + key := strings.ToLower(user) + profile := out[key] + if profile.FirstSeenAt == 0 { + profile.FirstSeenAt = now + } + profile.LastSeenAt = now + profile.Missions = appendUnique(profile.Missions, missionID) + profile.PersonaIds = appendUnique(profile.PersonaIds, personaID) + for _, tag := range tags { + profile.Tags = appendUnique(profile.Tags, tag) + } + prevCount := profile.SeenCount + if prevCount < 0 { + prevCount = 0 + } + eng := float64(acc.EngagementScore) + if prevCount == 0 { + profile.AvgEngagement = eng + } else { + profile.AvgEngagement = (profile.AvgEngagement*float64(prevCount) + eng) / float64(prevCount+1) + } + profile.SeenCount = prevCount + 1 + switch strings.TrimSpace(acc.Status) { + case missionentity.SimilarAccountStatusExcluded: + profile.Status = accountentity.KnownAccountStatusExcluded + case missionentity.SimilarAccountStatusRecommended: + if profile.Status == accountentity.KnownAccountStatusExcluded { + profile.Status = "" + } + } + out[key] = profile + } + return out +} + +func appendUnique(items []string, value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return items + } + for _, item := range items { + if item == value { + return items + } + } + return append(items, value) +} diff --git a/backend/internal/library/viral/merge_accounts.go b/backend/internal/library/viral/merge_accounts.go new file mode 100644 index 0000000..14e4170 --- /dev/null +++ b/backend/internal/library/viral/merge_accounts.go @@ -0,0 +1,190 @@ +package viral + +import ( + "sort" + "strings" + + missionentity "haixun-backend/internal/model/copy_mission/domain/entity" +) + +// MergeSimilarAccounts merges previous and newly discovered similar accounts +// keyed by lowercase username. Newly discovered accounts overwrite per-account +// statistics, but pinned/excluded status and previously seen accounts are +// preserved. Pinned accounts stay at the top even when not refreshed. +func MergeSimilarAccounts(prev, next []missionentity.SimilarAccount) []missionentity.SimilarAccount { + if len(prev) == 0 { + return capSimilarAccounts(append([]missionentity.SimilarAccount(nil), next...)) + } + if len(next) == 0 { + return prioritizePinnedSimilarAccounts(append([]missionentity.SimilarAccount(nil), prev...)) + } + + byUser := map[string]missionentity.SimilarAccount{} + newOrder := make([]string, 0, len(next)) + for _, item := range next { + key := strings.ToLower(strings.TrimSpace(item.Username)) + if key == "" { + continue + } + if _, ok := byUser[key]; !ok { + newOrder = append(newOrder, key) + } + byUser[key] = mergeAccountRecord(byUser[key], item) + } + + prevOrder := make([]string, 0, len(prev)) + for _, item := range prev { + key := strings.ToLower(strings.TrimSpace(item.Username)) + if key == "" { + continue + } + if _, ok := byUser[key]; ok { + byUser[key] = mergeAccountRecord(item, byUser[key]) + continue + } + byUser[key] = item + prevOrder = append(prevOrder, key) + } + + out := make([]missionentity.SimilarAccount, 0, len(newOrder)+len(prevOrder)) + for _, key := range newOrder { + if acc, ok := byUser[key]; ok { + out = append(out, acc) + } + } + for _, key := range prevOrder { + if acc, ok := byUser[key]; ok { + out = append(out, acc) + } + } + return prioritizePinnedSimilarAccounts(out) +} + +func mergeAccountRecord(prev, next missionentity.SimilarAccount) missionentity.SimilarAccount { + out := next + out.MatchedSource = unionMatchedSource(prev.MatchedSource, next.MatchedSource) + switch prev.Status { + case missionentity.SimilarAccountStatusPinned, missionentity.SimilarAccountStatusExcluded: + out.Status = prev.Status + default: + if strings.TrimSpace(out.Status) == "" { + out.Status = missionentity.SimilarAccountStatusRecommended + } + } + return out +} + +func prioritizePinnedSimilarAccounts(accounts []missionentity.SimilarAccount) []missionentity.SimilarAccount { + if len(accounts) == 0 { + return nil + } + pinned := make([]missionentity.SimilarAccount, 0) + rest := make([]missionentity.SimilarAccount, 0, len(accounts)) + for _, item := range accounts { + if item.Status == missionentity.SimilarAccountStatusPinned { + pinned = append(pinned, item) + } else { + rest = append(rest, item) + } + } + return capSimilarAccounts(append(pinned, rest...)) +} + +func capSimilarAccounts(accounts []missionentity.SimilarAccount) []missionentity.SimilarAccount { + if len(accounts) <= MaxSimilarAccounts { + return accounts + } + pinned := make([]missionentity.SimilarAccount, 0) + rest := make([]missionentity.SimilarAccount, 0, len(accounts)) + for _, item := range accounts { + if item.Status == missionentity.SimilarAccountStatusPinned { + pinned = append(pinned, item) + } else { + rest = append(rest, item) + } + } + remaining := MaxSimilarAccounts - len(pinned) + if remaining < 0 { + return pinned[:MaxSimilarAccounts] + } + if len(rest) > remaining { + rest = rest[:remaining] + } + return append(pinned, rest...) +} + +func unionMatchedSource(a, b []string) []string { + if len(a) == 0 && len(b) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]string, 0, len(a)+len(b)) + for _, item := range append(append([]string{}, a...), b...) { + s := strings.TrimSpace(item) + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + sort.Strings(out) + if len(out) == 0 { + return nil + } + return out +} + +func ExcludedSimilarAccountUsernames(accounts []missionentity.SimilarAccount) []string { + out := make([]string, 0) + seen := map[string]struct{}{} + for _, item := range accounts { + if item.Status != missionentity.SimilarAccountStatusExcluded { + continue + } + user := strings.TrimSpace(item.Username) + if user == "" { + continue + } + key := strings.ToLower(user) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, user) + } + return out +} + +func SimilarAccountUsernames(accounts []missionentity.SimilarAccount) []string { + out := make([]string, 0, len(accounts)) + seen := map[string]struct{}{} + for _, item := range accounts { + user := strings.TrimSpace(item.Username) + if user == "" { + continue + } + key := strings.ToLower(user) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, user) + } + return out +} + +func isExcluded(excluded []string, username string) bool { + user := strings.ToLower(strings.TrimSpace(username)) + if user == "" { + return false + } + for _, item := range excluded { + if strings.ToLower(strings.TrimSpace(item)) == user { + return true + } + } + return false +} diff --git a/backend/internal/library/viral/mission_inspire.go b/backend/internal/library/viral/mission_inspire.go index 9d52d91..32f4718 100644 --- a/backend/internal/library/viral/mission_inspire.go +++ b/backend/internal/library/viral/mission_inspire.go @@ -1,9 +1,13 @@ package viral import ( + "context" "encoding/json" "fmt" + "sort" "strings" + + "haixun-backend/internal/library/placement" ) type MissionInspireInput struct { @@ -17,6 +21,7 @@ type MissionInspireInput struct { PersonaPillars []string RecentMissionLabels []string RecentSeedQueries []string + UserKeyword string TrendSnippets []MissionInspireTrendSnippet WebSearchProvider string LLMOnly bool @@ -29,6 +34,16 @@ type MissionInspireTrendSnippet struct { URL string } +type MissionInspireThreadsInput struct { + Keyword string + PersonaBrief string + StyleBenchmark string + Pillars []string + Questions []string + Member placement.MemberContext + Crawler placement.CrawlerSearchFn +} + type MissionInspireOutput struct { Label string SeedQuery string @@ -38,10 +53,10 @@ type MissionInspireOutput struct { } func BuildMissionInspireSystemPrompt() string { - return strings.TrimSpace(`你是 Threads 拷貝忍者的「靈感骰子」顧問。根據近期網路熱搜、Google Trends 類訊號與創作者人設,產出一組**全新**拷貝任務草稿。 + return strings.TrimSpace(`你是 Threads 拷貝忍者的「靈感骰子」顧問。根據近期 Threads 熱門貼文、網路搜尋訊號與創作者人設,產出一組**全新**拷貝任務草稿。 規則: -1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;不要編造不存在的時事 +1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;不要編造不存在的時事或熱門貼文 2. 若趨勢訊號為空(未連線網路搜尋),**必須**改依人設、受眾痛點與常見 Threads 討論型態推測「近期可能被搜尋」的話題,並在 trendReason 說明推測理由(不要假裝有外部熱搜來源) 3. label:任務名稱,6~18 字,像企劃案標題,不要標點堆疊 4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點 @@ -85,6 +100,11 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string { b.WriteString(strings.Join(in.PersonaPillars, "、")) b.WriteString("\n") } + if keyword := strings.TrimSpace(in.UserKeyword); keyword != "" { + b.WriteString("【使用者指定靈感關鍵字】") + b.WriteString(keyword) + b.WriteString("\n") + } if len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 { b.WriteString("【近期已做過的任務(請避開)】\n") for _, label := range in.RecentMissionLabels { @@ -103,11 +123,11 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string { } } if in.LLMOnly { - b.WriteString("【模式】未設定 Web Search API key,請純依人設與受眾推測靈感(勿假裝有 Google 熱搜)\n") + b.WriteString("【模式】未取得 Threads/API/搜尋素材,請純依人設、使用者關鍵字與受眾推測靈感(勿假裝有外部熱搜)\n") } else if provider := strings.TrimSpace(in.WebSearchProvider); provider != "" { b.WriteString("【趨勢來源】") b.WriteString(provider) - b.WriteString(" 網路搜尋\n") + b.WriteString("\n") } b.WriteString("【近期趨勢訊號】\n") if len(in.TrendSnippets) == 0 { @@ -148,6 +168,152 @@ func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string { return queries } +func InspireThreadsSearchQueries(in MissionInspireThreadsInput) []string { + seen := map[string]struct{}{} + out := make([]string, 0, 6) + add := func(q string) { + q = cleanInspireQuery(q) + if q == "" { + return + } + if _, ok := seen[q]; ok { + return + } + seen[q] = struct{}{} + out = append(out, q) + } + + keyword := cleanInspireQuery(in.Keyword) + if keyword != "" { + add(keyword) + add(keyword + " 心情") + add(keyword + " 經驗") + add(keyword + " 請問") + } + for _, pillar := range in.Pillars { + if len(out) >= 6 { + break + } + add(pillar) + if keyword != "" { + add(keyword + " " + pillar) + } + } + for _, question := range in.Questions { + if len(out) >= 6 { + break + } + add(question) + } + if len(out) == 0 { + context := strings.TrimSpace(in.PersonaBrief + " " + in.StyleBenchmark) + if len([]rune(context)) > 24 { + context = string([]rune(context)[:24]) + } + add(context) + } + if len(out) > 6 { + return out[:6] + } + return out +} + +func CollectMissionInspireThreadsTrends(ctx context.Context, in MissionInspireThreadsInput) []MissionInspireTrendSnippet { + if !in.Member.HasDiscoverPath() { + return nil + } + if !in.Member.AllowsCrawler && !in.Member.AllowsThreadsAPI { + return nil + } + queries := InspireThreadsSearchQueries(in) + if len(queries) == 0 { + return nil + } + + type scoredSnippet struct { + item MissionInspireTrendSnippet + score int + } + seen := map[string]struct{}{} + collected := make([]scoredSnippet, 0, 12) + for _, query := range queries { + posts, _, err := placement.Discover(ctx, placement.DiscoverRequest{ + Query: query, + Keyword: query, + Limit: 8, + Member: in.Member, + Crawler: in.Crawler, + }) + if err != nil { + continue + } + for _, post := range posts { + text := strings.TrimSpace(post.Text) + if len([]rune(text)) < 18 { + continue + } + key := strings.TrimSpace(post.Permalink) + if key == "" { + key = strings.TrimSpace(post.ExternalID) + } + if key == "" { + key = text + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + title := strings.TrimSpace(post.Author) + if title != "" { + title = "Threads @" + strings.TrimPrefix(title, "@") + } else { + title = "Threads 熱門貼文" + } + collected = append(collected, scoredSnippet{ + item: MissionInspireTrendSnippet{ + Query: query, + Title: title, + Snippet: shortenRunes(text, 180), + URL: strings.TrimSpace(post.Permalink), + }, + score: post.LikeCount + post.ReplyCount*2, + }) + } + if len(collected) >= 12 { + break + } + } + sort.SliceStable(collected, func(i, j int) bool { + return collected[i].score > collected[j].score + }) + if len(collected) > 8 { + collected = collected[:8] + } + out := make([]MissionInspireTrendSnippet, 0, len(collected)) + for _, item := range collected { + out = append(out, item.item) + } + return out +} + +func cleanInspireQuery(raw string) string { + raw = strings.TrimSpace(raw) + raw = strings.ReplaceAll(raw, "\n", " ") + raw = strings.Join(strings.Fields(raw), " ") + if len([]rune(raw)) > 24 { + raw = string([]rune(raw)[:24]) + } + return raw +} + +func shortenRunes(raw string, max int) string { + raw = strings.TrimSpace(raw) + if max <= 0 || len([]rune(raw)) <= max { + return raw + } + return string([]rune(raw)[:max]) + "…" +} + func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) { payload, err := extractCopyMapJSON(raw) if err != nil { diff --git a/backend/internal/library/viral/mission_inspire_test.go b/backend/internal/library/viral/mission_inspire_test.go index 0f7e880..990e91f 100644 --- a/backend/internal/library/viral/mission_inspire_test.go +++ b/backend/internal/library/viral/mission_inspire_test.go @@ -25,7 +25,7 @@ func TestBuildMissionInspireUserPromptLLMOnly(t *testing.T) { PersonaBrief: "職場焦慮", LLMOnly: true, }) - if !strings.Contains(prompt, "未設定 Web Search API key") { + if !strings.Contains(prompt, "未取得 Threads/API/搜尋素材") { t.Fatalf("expected llm-only hint in prompt: %s", prompt) } if !strings.Contains(prompt, "無外部趨勢結果") { diff --git a/backend/internal/library/viral/mission_research_map.go b/backend/internal/library/viral/mission_research_map.go index d0d1876..01ee564 100644 --- a/backend/internal/library/viral/mission_research_map.go +++ b/backend/internal/library/viral/mission_research_map.go @@ -18,19 +18,20 @@ type MissionResearchMap struct { } func BuildMissionResearchMapSystemPrompt() string { - return strings.TrimSpace(`你是 Threads 爆款/對標研究顧問。目標:幫創作者找到「近期熱門、高互動、值得仿寫」的話題與搜尋方向。 + return strings.TrimSpace(`你是 Threads 拷貝任務的「延伸知識與相似帳號研究顧問」。目標:把使用者的完整意圖延伸成可產文的知識地圖,並提供適合人工參考的相似帳號搜尋方向;不要把題目壓扁成泛泛短詞。 規則: 1. audienceSummary:必填,2~4 句描述「受眾是誰」(年齡/情境/痛點/會在 Threads 搜什麼),不要只寫人設本人 -2. 聚焦「最近會在 Threads 被搜、被討論」的話題,不要寫成學術報告 -3. contentGoal:找到近期互動佳、結構可模仿的爆款貼文 -4. pillars:可模仿方向(語錄型、故事型、清單型等),至少 4 個;**必須是字串陣列**,不要用 {title:...} 物件 -5. questions:受眾會搜的短問題,5+ 個;字串陣列 -6. exclusions:不要模仿的內容,至少 4 個;字串陣列 -7. suggestedTags:6~8 個「像真人會在 Threads 搜尋框打的字」 - - 每個含 tag, reason, searchIntent(痛點|知識|經驗|對比|工具|語錄), searchType(短詞|情境|語錄) - - tag 2~10 字,不要標點、不要完整句子、不要像文章標題 -8. benchmarkNotes:怎樣算值得仿的爆款(互動、hook 清楚) +2. 必須保留使用者原始題目的核心主詞、情境與目的。例如「備孕男生要吃什麼保健品」不可簡化成「老公吃什麼」 +3. contentGoal:用延伸知識與人設產出可發的 Threads 內容;爆款貼文只作為可選參考,不是必經來源 +4. pillars:延伸知識支柱,至少 4 個;**必須是字串陣列**,每項要能支撐一篇內容,例如營養素、檢查、生活習慣、常見迷思 +5. questions:受眾會搜尋或想問的完整問題,5+ 個;字串陣列,需保留主詞與情境 +6. exclusions:不要寫的內容,至少 4 個;包含過度醫療承諾、偏方、恐嚇、與題目無關的家庭日常 +7. suggestedTags:6~8 個「搜尋查詢」,不是短 hashtag + - 每個含 tag, reason, searchIntent(痛點|知識|經驗|對比|工具|帳號), searchType(查詢|情境|帳號) + - tag 6~22 字,必須保留核心意圖;可以像真人搜尋句,例如「備孕男生保健品」「精蟲品質吃什麼」「男性備孕葉酸鋅」 + - 不要輸出「老公吃什麼」「多閱讀」這種失去主題的泛詞 +8. benchmarkNotes:整理延伸知識重點與人工看相似帳號時的觀察方向;可列 3~5 個重點 9. 繁體中文;只回傳 JSON(含 audienceSummary)`) } @@ -68,7 +69,7 @@ func BuildMissionResearchMapUserPrompt(in CopyResearchMapInput) string { } b.WriteString("\n") } - b.WriteString("\n請產出拷貝任務研究地圖 JSON(必填 audienceSummary 與 suggestedTags 陣列)。") + b.WriteString("\n請產出拷貝任務研究地圖 JSON。請把 suggestedTags 當成完整搜尋查詢,不要壓成短 hashtag。") return b.String() } diff --git a/backend/internal/library/viral/mission_research_map_test.go b/backend/internal/library/viral/mission_research_map_test.go index e6ea412..6b58593 100644 --- a/backend/internal/library/viral/mission_research_map_test.go +++ b/backend/internal/library/viral/mission_research_map_test.go @@ -1,6 +1,9 @@ package viral -import "testing" +import ( + "strings" + "testing" +) func TestParseMissionResearchMapOutput_ObjectPillars(t *testing.T) { raw := `{ @@ -53,3 +56,13 @@ func TestParseMissionResearchMapOutput_StringSuggestedTags(t *testing.T) { t.Fatalf("expected 4 tags, got %#v", out.SuggestedTags) } } + +func TestBuildMissionResearchMapSystemPromptPreservesIntent(t *testing.T) { + prompt := BuildMissionResearchMapSystemPrompt() + if !strings.Contains(prompt, "不可簡化成「老公吃什麼」") { + t.Fatalf("expected prompt to warn against intent-flattening, got %s", prompt) + } + if !strings.Contains(prompt, "搜尋查詢") { + t.Fatalf("expected suggested tags to be framed as search queries") + } +} diff --git a/backend/internal/library/viral/mission_topic.go b/backend/internal/library/viral/mission_topic.go new file mode 100644 index 0000000..7db6f6d --- /dev/null +++ b/backend/internal/library/viral/mission_topic.go @@ -0,0 +1,131 @@ +package viral + +import ( + "strings" + "unicode" + + "haixun-backend/internal/library/placement" +) + +type topicMatchTerms struct { + Anchors []string + Terms []string +} + +func missionTopicMatchTerms(seed, label string, hints []string) topicMatchTerms { + seenAnchors := map[string]struct{}{} + seenTerms := map[string]struct{}{} + out := topicMatchTerms{} + addTerm := func(term string, anchor bool) { + term = normaliseTopicToken(term) + if term == "" || isGenericTopicToken(term) { + return + } + if _, ok := seenTerms[term]; !ok { + seenTerms[term] = struct{}{} + out.Terms = append(out.Terms, term) + } + if anchor { + if _, ok := seenAnchors[term]; !ok { + seenAnchors[term] = struct{}{} + out.Anchors = append(out.Anchors, term) + } + } + } + addSource := func(raw string, anchor bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return + } + compact := compactTopicPhrase(raw) + addTerm(compact, anchor) + for _, token := range splitTopicTokens(raw) { + addTerm(token, anchor) + } + } + + addSource(seed, true) + addSource(label, true) + anchorHints := len(out.Anchors) == 0 + for _, hint := range hints { + addSource(hint, anchorHints) + } + return out +} + +// missionPostMatchesTopic is intentionally stricter than topicTopicHits: +// mission scan candidates must match the post body, not only the query/tag +// that produced the crawl result. This prevents crawler/search drift from +// admitting high-engagement but unrelated posts. +func missionPostMatchesTopic(post placement.ScanCandidate, terms topicMatchTerms) bool { + text := strings.ToLower(strings.TrimSpace(post.Text)) + if len(terms.Anchors) == 0 && len(terms.Terms) == 0 { + return text != "" + } + if text == "" { + return false + } + for _, term := range terms.Anchors { + if term != "" && strings.Contains(text, term) { + return true + } + } + hits := 0 + for _, term := range terms.Terms { + if term != "" && strings.Contains(text, term) { + hits++ + if hits >= 2 { + return true + } + } + } + return false +} + +func splitTopicTokens(raw string) []string { + return strings.FieldsFunc(raw, func(r rune) bool { + if unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) { + return true + } + switch r { + case ',', '。', '、', ':', ';', '!', '?', '「', '」', '『', '』', '(', ')', '【', '】': + return true + default: + return false + } + }) +} + +func compactTopicPhrase(raw string) string { + var b strings.Builder + for _, r := range raw { + if unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) { + continue + } + b.WriteRune(r) + } + return b.String() +} + +func normaliseTopicToken(raw string) string { + token := strings.ToLower(strings.TrimSpace(raw)) + if token == "" { + return "" + } + token = strings.Trim(token, "##,,.。::;;!!??()()[]【】\"'「」『』") + if len([]rune(token)) < 2 { + return "" + } + return token +} + +func isGenericTopicToken(token string) bool { + switch token { + case "分享", "心得", "推薦", "請問", "求助", "問題", "方法", "技巧", "經驗", + "熱門", "話題", "最近", "大家", "有人", "可以", "如何", "怎麼", "為什麼", + "品質", "閱讀", "更多", "多閱讀", "老公", "老婆", "男友", "女友": + return true + default: + return false + } +} diff --git a/backend/internal/library/viral/reference_accounts.go b/backend/internal/library/viral/reference_accounts.go index f71a743..eff91f4 100644 --- a/backend/internal/library/viral/reference_accounts.go +++ b/backend/internal/library/viral/reference_accounts.go @@ -2,10 +2,13 @@ package viral import ( "fmt" + "math" "sort" "strings" + "haixun-backend/internal/library/clock" "haixun-backend/internal/library/placement" + libthreads "haixun-backend/internal/library/threadsapi" missionentity "haixun-backend/internal/model/copy_mission/domain/entity" ) @@ -17,11 +20,63 @@ const ( RefVerifiedMinBestLikes = 10 ) +// ReferenceRankWeights controls the weighted sort key applied to ranked +// reference authors. Defaults preserve historical precedence (verified first, +// then follower count, then total engagement, then best single-post +// engagement) and introduce topic relevance as a tie-breaker multiplier. +type ReferenceRankWeights struct { + VerifiedW int + FollowerW int + TotalEngagementW int + BestEngagementW int + TopicRelevanceW int +} + +// DefaultReferenceRankWeights returns the canonical weights used by +// BuildReferenceAccountsFromScan. Callers may pass a customised copy via +// ReferenceAccountInput in a future iteration; Phase 1 keeps it internal. +func DefaultReferenceRankWeights() ReferenceRankWeights { + return ReferenceRankWeights{ + VerifiedW: 4, + FollowerW: 2, + TotalEngagementW: 1, + BestEngagementW: 1, + TopicRelevanceW: 2, + } +} + +// rankScore converts aggregated author signals into a weighted integer sort +// key. Follower count is log-scaled so 10M-follower mega accounts do not +// dominate niche candidates with high topic relevance. +func (w ReferenceRankWeights) rankScore(item referenceAuthorAgg) int { + score := 0 + if item.verified { + score += w.VerifiedW * 1000 + } + followerBucket := 0 + switch { + case item.followerCount >= 1_000_000: + followerBucket = 4 + case item.followerCount >= 100_000: + followerBucket = 3 + case item.followerCount >= 10_000: + followerBucket = 2 + case item.followerCount >= 1_000: + followerBucket = 1 + } + score += w.FollowerW * followerBucket * 100 + score += w.TotalEngagementW * item.totalEngagement + score += w.BestEngagementW * item.bestEngagement + score += w.TopicRelevanceW * item.topicHits * 50 + return score +} + type ReferenceAccountInput struct { - SeedQuery string - Label string - Posts []placement.ScanCandidate - Limit int + SeedQuery string + Label string + Posts []placement.ScanCandidate + Limit int + ExcludedUsernames []string } type referenceAuthorAgg struct { @@ -33,8 +88,10 @@ type referenceAuthorAgg struct { bestLikes int bestReplies int postCount int + topicHits int sampleText string sampleSearchTag string + samplePermalink string } // BuildReferenceAccountsFromScan lists authors from patrol posts that match the @@ -54,12 +111,20 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool limit = MaxSimilarAccounts } byUser := map[string]referenceAuthorAgg{} + terms := normalisedTopicTerms(in.SeedQuery, in.Label) + weights := DefaultReferenceRankWeights() + now := clock.NowUnixNano() + topicDenom := math.Max(1, float64(len(terms))) for _, post := range in.Posts { user := strings.TrimSpace(post.Author) if user == "" || !isValidUsername(user) { continue } - if !postTopicRelevant(post, in.SeedQuery, in.Label) { + if isExcluded(in.ExcludedUsernames, user) { + continue + } + hits := topicTopicHits(post, terms) + if hits == 0 { continue } if strictQuality { @@ -85,6 +150,9 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool } prev.postCount++ prev.totalEngagement += post.EngagementScore + if hits > prev.topicHits { + prev.topicHits = hits + } if post.EngagementScore > prev.bestEngagement { prev.bestEngagement = post.EngagementScore prev.bestLikes = post.LikeCount @@ -95,6 +163,7 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool } prev.sampleText = text prev.sampleSearchTag = strings.TrimSpace(post.SearchTag) + prev.samplePermalink = strings.TrimSpace(post.Permalink) } byUser[key] = prev } @@ -106,6 +175,13 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool } } sort.Slice(ranked, func(i, j int) bool { + si := weights.rankScore(ranked[i]) + sj := weights.rankScore(ranked[j]) + if si != sj { + return si > sj + } + // stable historical tie-breakers (verified first, follower, + // total engagement, best engagement) preserved for determinism. if ranked[i].verified != ranked[j].verified { return ranked[i].verified } @@ -133,8 +209,12 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool Username: item.username, Reason: formatReferenceReason(item), Source: "scan", + MatchedSource: []string{"scan"}, Confidence: conf, - ProfileURL: "https://www.threads.net/@" + item.username, + Status: missionentity.SimilarAccountStatusRecommended, + TopicRelevance: float64(item.topicHits) / topicDenom, + LastSeenAt: now, + ProfileURL: libthreads.ProfileURLFromPermalink(item.samplePermalink, item.username), AuthorVerified: item.verified, FollowerCount: item.followerCount, EngagementScore: item.bestEngagement, @@ -164,28 +244,53 @@ func qualifiesReferenceAuthor(item referenceAuthorAgg, strictQuality bool) bool } func postTopicRelevant(post placement.ScanCandidate, seed, label string) bool { - text := strings.ToLower(strings.TrimSpace(post.Text)) - tag := strings.ToLower(strings.TrimSpace(post.SearchTag)) - terms := topicTerms(seed, label) - if len(terms) == 0 { - return text != "" || tag != "" - } - for _, term := range terms { - term = strings.ToLower(term) - if strings.Contains(text, term) || strings.Contains(tag, term) { - return true - } - } - return false + return topicTopicHits(post, normalisedTopicTerms(seed, label)) > 0 } -func topicTerms(seed, label string) []string { - out := []string{} - if s := strings.TrimSpace(seed); s != "" { - out = append(out, s) +// topicTopicHits counts how many normalised topic terms appear (case-folded +// substring match against the post's text and search tag). Returning a hit +// count (instead of a boolean) lets the ranking weight reward posts that are +// relevant across multiple seed/label tokens — a coarse but dependency-free +// CJK-friendly proxy for topic similarity. +func topicTopicHits(post placement.ScanCandidate, terms []string) int { + if len(terms) == 0 { + text := strings.TrimSpace(post.Text) + tag := strings.TrimSpace(post.SearchTag) + if text == "" && tag == "" { + return 0 + } + return 1 } - if l := strings.TrimSpace(label); l != "" { - out = append(out, l) + text := strings.ToLower(strings.TrimSpace(post.Text)) + tag := strings.ToLower(strings.TrimSpace(post.SearchTag)) + hits := 0 + for _, term := range terms { + if term == "" { + continue + } + if strings.Contains(text, term) || strings.Contains(tag, term) { + hits++ + } + } + return hits +} + +// normalisedTopicTerms lowercases and de-spaces the seed-query and label while +// also exposing coarse tokens split on whitespace and punctuation. It remains +// dependency-free and CJK-friendly, but avoids matching only a long exact phrase. +func normalisedTopicTerms(seed, label string) []string { + out := []string{} + terms := missionTopicMatchTerms(seed, label, nil) + seen := map[string]struct{}{} + for _, term := range append(append([]string{}, terms.Anchors...), terms.Terms...) { + if term == "" { + continue + } + if _, ok := seen[term]; ok { + continue + } + seen[term] = struct{}{} + out = append(out, term) } return out } diff --git a/backend/internal/library/viral/search_tags.go b/backend/internal/library/viral/search_tags.go index 70bd221..ac1a569 100644 --- a/backend/internal/library/viral/search_tags.go +++ b/backend/internal/library/viral/search_tags.go @@ -16,11 +16,13 @@ type SuggestedTag struct { SearchType string `json:"searchType,omitempty"` } -// PickDefaultSelectedTags chooses a lean set for scanning (saves search API quota). +// PickDefaultSelectedTags chooses a lean set of search queries. Prefer complete +// intent-preserving queries over short hashtags; short generic tags tend to +// drift away from niche copy-mission topics. func PickDefaultSelectedTags(tags []SuggestedTag) []string { short := []string{} - scenario := []string{} - quote := []string{} + query := []string{} + contextual := []string{} account := []string{} for _, item := range tags { @@ -34,17 +36,19 @@ func PickDefaultSelectedTags(tags []SuggestedTag) []string { continue } switch st { - case "短詞": - short = append(short, tag) - case "情境": - scenario = append(scenario, tag) - case "語錄": - quote = append(quote, tag) + case "查詢", "情境": + query = append(query, tag) + case "短詞", "語錄": + if len([]rune(tag)) >= 6 { + query = append(query, tag) + } else { + short = append(short, tag) + } default: if len([]rune(tag)) <= 4 { short = append(short, tag) } else { - scenario = append(scenario, tag) + contextual = append(contextual, tag) } } } @@ -62,20 +66,14 @@ func PickDefaultSelectedTags(tags []SuggestedTag) []string { seen[tag] = struct{}{} out = append(out, tag) } - for _, tag := range short { - if len(out) >= 2 { - break - } - add(tag) - } - for _, tag := range scenario { + for _, tag := range query { if len(out) >= 4 { break } add(tag) } - for _, tag := range quote { - if len(out) >= 5 { + for _, tag := range contextual { + if len(out) >= 4 { break } add(tag) @@ -86,13 +84,19 @@ func PickDefaultSelectedTags(tags []SuggestedTag) []string { } add(tag) } - for _, tag := range short { + for _, tag := range query { if len(out) >= DefaultSelectedTagCount { break } add(tag) } - for _, tag := range scenario { + for _, tag := range contextual { + if len(out) >= DefaultSelectedTagCount { + break + } + add(tag) + } + for _, tag := range short { if len(out) >= DefaultSelectedTagCount { break } diff --git a/backend/internal/library/viral/search_tags_test.go b/backend/internal/library/viral/search_tags_test.go index 30058e8..ce2c01c 100644 --- a/backend/internal/library/viral/search_tags_test.go +++ b/backend/internal/library/viral/search_tags_test.go @@ -27,3 +27,20 @@ func TestPickDefaultSelectedTags(t *testing.T) { seen[tag] = struct{}{} } } + +func TestPickDefaultSelectedTags_prefersIntentPreservingQueries(t *testing.T) { + tags := []SuggestedTag{ + {Tag: "老公吃什麼", SearchType: "短詞"}, + {Tag: "多閱讀", SearchType: "短詞"}, + {Tag: "備孕男生保健品", SearchType: "查詢"}, + {Tag: "精蟲品質吃什麼", SearchType: "查詢"}, + {Tag: "男性備孕葉酸鋅", SearchType: "情境"}, + } + out := PickDefaultSelectedTags(tags) + if len(out) < 2 { + t.Fatalf("expected selected queries, got %#v", out) + } + if out[0] != "備孕男生保健品" || out[1] != "精蟲品質吃什麼" { + t.Fatalf("expected complete intent queries first, got %#v", out) + } +} diff --git a/backend/internal/library/webpage/digest.go b/backend/internal/library/webpage/digest.go new file mode 100644 index 0000000..4f61628 --- /dev/null +++ b/backend/internal/library/webpage/digest.go @@ -0,0 +1,182 @@ +package webpage + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-shiori/go-readability" +) + +const ( + DefaultMaxPages = 6 + DefaultMaxMarkdown = 6000 + DefaultPerPageChars = 2500 + DefaultFetchTimeout = 18 * time.Second + DefaultMaxBodyBytes = 2 << 20 +) + +type FetchOptions struct { + MaxPages int + MaxMarkdown int + PerPageChars int + Timeout time.Duration + UserAgent string +} + +func (o FetchOptions) normalized() FetchOptions { + out := o + if out.MaxPages <= 0 { + out.MaxPages = DefaultMaxPages + } + if out.MaxMarkdown <= 0 { + out.MaxMarkdown = DefaultMaxMarkdown + } + if out.PerPageChars <= 0 { + out.PerPageChars = DefaultPerPageChars + } + if out.Timeout <= 0 { + out.Timeout = DefaultFetchTimeout + } + if strings.TrimSpace(out.UserAgent) == "" { + out.UserAgent = "ThreadMasterKnowledgeBot/1.0 (+https://thread-master.local)" + } + return out +} + +type Digest struct { + URL string + Title string + Markdown string + Error string +} + +// FetchDigests fetches readable page content and formats each page as lightweight markdown. +func FetchDigests(ctx context.Context, urls []string, opts FetchOptions) []Digest { + opts = opts.normalized() + seen := map[string]struct{}{} + out := make([]Digest, 0, min(len(urls), opts.MaxPages)) + for _, raw := range urls { + if len(out) >= opts.MaxPages { + break + } + u := strings.TrimSpace(raw) + if u == "" { + continue + } + key := strings.ToLower(u) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if !FetchableURL(u) { + continue + } + out = append(out, fetchOne(ctx, u, opts)) + } + return out +} + +func fetchOne(ctx context.Context, pageURL string, opts FetchOptions) Digest { + d := Digest{URL: pageURL} + reqCtx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pageURL, nil) + if err != nil { + d.Error = err.Error() + return d + } + req.Header.Set("User-Agent", opts.UserAgent) + req.Header.Set("Accept", "text/html,application/xhtml+xml") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + d.Error = err.Error() + return d + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + d.Error = fmt.Sprintf("http %d", resp.StatusCode) + return d + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxBodyBytes)) + if err != nil { + d.Error = err.Error() + return d + } + parsed, err := url.Parse(pageURL) + if err != nil { + d.Error = err.Error() + return d + } + article, err := readability.FromReader(strings.NewReader(string(body)), parsed) + if err != nil { + d.Error = err.Error() + return d + } + title := strings.TrimSpace(article.Title) + text := strings.TrimSpace(article.TextContent) + if title == "" && text == "" { + d.Error = "empty article" + return d + } + d.Title = title + d.Markdown = toMarkdown(title, text, opts.PerPageChars) + return d +} + +func toMarkdown(title, text string, maxChars int) string { + var b strings.Builder + if title != "" { + b.WriteString("# ") + b.WriteString(title) + b.WriteString("\n\n") + } + if text != "" { + b.WriteString(text) + } + out := strings.TrimSpace(b.String()) + if maxChars > 0 && len([]rune(out)) > maxChars { + runes := []rune(out) + out = strings.TrimSpace(string(runes[:maxChars])) + "…" + } + return out +} + +// FetchableURL returns false for unsupported or crawler-owned hosts. +func FetchableURL(raw string) bool { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Scheme == "" || u.Host == "" { + return false + } + switch strings.ToLower(u.Scheme) { + case "http", "https": + default: + return false + } + host := strings.ToLower(u.Hostname()) + if host == "localhost" || strings.HasSuffix(host, ".local") { + return false + } + if strings.Contains(host, "threads.net") || strings.Contains(host, "threads.com") { + return false + } + path := strings.ToLower(u.Path) + if strings.HasSuffix(path, ".pdf") || strings.HasSuffix(path, ".zip") { + return false + } + return true +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} \ No newline at end of file diff --git a/backend/internal/library/webpage/digest_test.go b/backend/internal/library/webpage/digest_test.go new file mode 100644 index 0000000..b0cc541 --- /dev/null +++ b/backend/internal/library/webpage/digest_test.go @@ -0,0 +1,43 @@ +package webpage + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFetchableURL(t *testing.T) { + if FetchableURL("https://example.com/article") != true { + t.Fatal("expected fetchable") + } + if FetchableURL("https://www.threads.net/@foo") != false { + t.Fatal("threads should be skipped") + } + if FetchableURL("ftp://example.com") != false { + t.Fatal("ftp should be skipped") + } +} + +func TestFetchDigests_ExtractsMarkdown(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`備孕指南

備孕指南

葉酸與鋅很重要,作息要穩定。

`)) + })) + defer srv.Close() + + digests := FetchDigests(context.Background(), []string{srv.URL}, FetchOptions{MaxPages: 1}) + if len(digests) != 1 { + t.Fatalf("digests = %v", digests) + } + if digests[0].Error != "" { + t.Fatalf("unexpected error: %s", digests[0].Error) + } + if digests[0].Markdown == "" { + t.Fatal("expected markdown") + } + if !strings.Contains(digests[0].Markdown, "備孕指南") || !strings.Contains(digests[0].Markdown, "葉酸") { + t.Fatalf("markdown = %q", digests[0].Markdown) + } +} \ No newline at end of file diff --git a/backend/internal/library/webpage/format.go b/backend/internal/library/webpage/format.go new file mode 100644 index 0000000..4362d0a --- /dev/null +++ b/backend/internal/library/webpage/format.go @@ -0,0 +1,52 @@ +package webpage + +import ( + "strings" +) + +// FormatMarkdownBlock renders digests for LLM prompts. +func FormatMarkdownBlock(digests []Digest, maxTotal int) string { + if len(digests) == 0 { + return "" + } + if maxTotal <= 0 { + maxTotal = DefaultMaxMarkdown + } + var b strings.Builder + used := 0 + for i, item := range digests { + if used >= maxTotal { + break + } + section := formatDigestSection(item) + if section == "" { + continue + } + if i > 0 { + b.WriteString("\n\n---\n\n") + } + remain := maxTotal - used + runes := []rune(section) + if len(runes) > remain { + section = string(runes[:remain]) + "…" + } + b.WriteString(section) + used += len([]rune(section)) + } + return strings.TrimSpace(b.String()) +} + +func formatDigestSection(item Digest) string { + if strings.TrimSpace(item.Markdown) != "" { + var b strings.Builder + b.WriteString("來源:") + b.WriteString(item.URL) + b.WriteString("\n") + b.WriteString(item.Markdown) + return strings.TrimSpace(b.String()) + } + if strings.TrimSpace(item.Error) == "" { + return "" + } + return "來源:" + item.URL + "\n(抓取失敗:" + item.Error + ")" +} \ No newline at end of file diff --git a/backend/internal/library/websearch/client.go b/backend/internal/library/websearch/client.go index 295fd15..5742e29 100644 --- a/backend/internal/library/websearch/client.go +++ b/backend/internal/library/websearch/client.go @@ -117,11 +117,12 @@ func (a *braveAdapter) Search(ctx context.Context, opts SearchOptions) (SearchRe mode = libbrave.ModeThreadsDiscover } res, err := a.client.Search(ctx, libbrave.SearchOptions{ - Query: opts.Query, - Limit: opts.Limit, - Mode: mode, - Country: firstNonEmpty(opts.Country, a.country), - SearchLang: firstNonEmpty(opts.SearchLang, a.searchLang), + Query: opts.Query, + Limit: opts.Limit, + Mode: mode, + Country: firstNonEmpty(opts.Country, a.country), + SearchLang: firstNonEmpty(opts.SearchLang, a.searchLang), + StartPublishedDate: opts.StartPublishedDate, }) return toBraveResponse(res.Query, res.Status, a.provider, res.Results, err) } diff --git a/backend/internal/logic/copy_mission/capabilities.go b/backend/internal/logic/copy_mission/capabilities.go new file mode 100644 index 0000000..98e8c57 --- /dev/null +++ b/backend/internal/logic/copy_mission/capabilities.go @@ -0,0 +1,16 @@ +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/delete_copy_mission_matrix_drafts_logic.go b/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go new file mode 100644 index 0000000..6a96cef --- /dev/null +++ b/backend/internal/logic/copy_mission/delete_copy_mission_matrix_drafts_logic.go @@ -0,0 +1,52 @@ +package copy_mission + +import ( + "context" + "fmt" + "strings" + + copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type DeleteCopyMissionMatrixDraftsLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteCopyMissionMatrixDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCopyMissionMatrixDraftsLogic { + return &DeleteCopyMissionMatrixDraftsLogic{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 +} \ No newline at end of file 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 new file mode 100644 index 0000000..0e7a121 --- /dev/null +++ b/backend/internal/logic/copy_mission/expand_copy_mission_graph_logic.go @@ -0,0 +1,96 @@ +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" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ExpandCopyMissionGraphLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewExpandCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ExpandCopyMissionGraphLogic { + return &ExpandCopyMissionGraphLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +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 + } + + 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 +} \ No newline at end of file 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 5ce222a..0971772 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 @@ -7,7 +7,10 @@ import ( app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" + libcopy "haixun-backend/internal/library/copymission" + libkg "haixun-backend/internal/library/knowledge" libmatrix "haixun-backend/internal/library/matrix" + libweb "haixun-backend/internal/library/webpage" libprompt "haixun-backend/internal/library/prompt" "haixun-backend/internal/library/style8d" domai "haixun-backend/internal/model/ai/domain/usecase" @@ -43,18 +46,22 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix( if err != nil { return nil, err } - if mission.Status != string(missionentity.StatusScanned) && + if mission.Status != string(missionentity.StatusMapped) && + mission.Status != string(missionentity.StatusScanned) && mission.Status != string(missionentity.StatusDrafted) { - return nil, app.For(code.Persona).ResInvalidState("請先完成海巡再產出內容矩陣") + return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣") } - if len(mission.SelectedTags) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("請先選擇海巡標籤") + if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { + return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖") } 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 對標分析") } @@ -79,12 +86,33 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix( return nil, err } samples := matrixSamplesFromScanPosts(posts) - researchBlock := libmatrix.FormatCopyResearchMapBlock( + 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, @@ -110,7 +138,7 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix( if err != nil { return nil, err } - result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{ + parsed, err := libmatrix.GenerateCopyOutput(l.ctx, l.svcCtx.AI, domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ @@ -120,11 +148,7 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix( Messages: []domai.Message{ {Role: "user", Content: userPrompt}, }, - }) - if err != nil { - return nil, err - } - parsed, err := libmatrix.ParseGenerateOutput(result.Text) + }, count) if err != nil { return nil, app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error()) } @@ -169,6 +193,17 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix( }, 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 { 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 new file mode 100644 index 0000000..c58d7eb --- /dev/null +++ b/backend/internal/logic/copy_mission/get_copy_mission_graph_logic.go @@ -0,0 +1,43 @@ +package copy_mission + +import ( + "context" + "strings" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetCopyMissionGraphLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionGraphLogic { + return &GetCopyMissionGraphLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +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 +} \ No newline at end of file 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 7222a19..c3d5fef 100644 --- a/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go +++ b/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go @@ -25,12 +25,13 @@ func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) return &InspireCopyMissionLogic{ctx: ctx, svcCtx: svcCtx} } -func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissionsPath) (*types.CopyMissionInspirationData, error) { +func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (*types.CopyMissionInspirationData, error) { tenantID, uid, err := actorFrom(l.ctx) if err != nil { return nil, err } personaID := strings.TrimSpace(req.PersonaID) + userKeyword := strings.TrimSpace(req.Keyword) persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) if err != nil { return nil, err @@ -59,25 +60,42 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi trendSnippets := []libviral.MissionInspireTrendSnippet{} webSearchProvider := "" + trendSourceLabel := "" llmOnly := true research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) - if researchErr == nil && placement.WebSearchAvailable(research) { + if researchErr == nil { memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) if memberErr == nil { - webClient := websearch.New(memberCtx.WebSearchConfig()) - trendSnippets = libviral.CollectMissionInspireTrends( - l.ctx, - webClient, - memberCtx, - persona.Brief, - persona.StyleBenchmark, - ) - webSearchProvider = memberCtx.WebSearchProviderLabel() - llmOnly = len(trendSnippets) == 0 + 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 + webSearchUsed := len(trendSnippets) > 0 && webSearchProvider != "" credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid) if err != nil { @@ -110,8 +128,9 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...), RecentMissionLabels: recentLabels, RecentSeedQueries: recentSeeds, + UserKeyword: userKeyword, TrendSnippets: trendSnippets, - WebSearchProvider: webSearchProvider, + WebSearchProvider: trendSourceLabel, LLMOnly: llmOnly, }), }, @@ -136,9 +155,12 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi }) } - message := "已依近期趨勢產出任務靈感,可微調後建立任務" - if !webSearchUsed { - message = "未設定搜尋 API key,已改由 LLM 依人設推測靈感;補上 Brave/Exa key 可取得更貼近熱搜的結果" + message := "已依近期 Threads 熱門素材產出任務靈感,可微調後建立任務" + if webSearchUsed { + message = "已依近期網路趨勢產出任務靈感,可微調後建立任務" + } + if len(trendSnippets) == 0 { + message = "未取得外部趨勢素材,已改由 LLM 依人設與關鍵字推測靈感;同步 Chrome Session、完成 Threads API 或補上 Brave/Exa key 可更貼近熱搜" } return &types.CopyMissionInspirationData{ @@ -152,3 +174,17 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi Message: message, }, nil } + +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) + } +} diff --git a/backend/internal/logic/copy_mission/knowledge_graph_mapper.go b/backend/internal/logic/copy_mission/knowledge_graph_mapper.go new file mode 100644 index 0000000..dfb98c3 --- /dev/null +++ b/backend/internal/logic/copy_mission/knowledge_graph_mapper.go @@ -0,0 +1,67 @@ +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, + } +} \ No newline at end of file diff --git a/backend/internal/logic/copy_mission/mapper.go b/backend/internal/logic/copy_mission/mapper.go index ca857c1..8812738 100644 --- a/backend/internal/logic/copy_mission/mapper.go +++ b/backend/internal/logic/copy_mission/mapper.go @@ -64,6 +64,14 @@ func toMissionPatch(req *types.UpdateCopyMissionReq) missiondomain.MissionPatch 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 } @@ -74,6 +82,19 @@ func toMissionPatch(req *types.UpdateCopyMissionReq) missiondomain.MissionPatch 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 { @@ -90,7 +111,11 @@ func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData 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, @@ -100,6 +125,19 @@ func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData 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, @@ -107,14 +145,18 @@ func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData 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...), - SuggestedTags: tags, - SimilarAccounts: accounts, - BenchmarkNotes: item.ResearchMap.BenchmarkNotes, + 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, @@ -151,7 +193,11 @@ func toEntityResearchMap(data types.CopyMissionResearchMapData) missionentity.Re 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, @@ -161,15 +207,31 @@ func toEntityResearchMap(data types.CopyMissionResearchMapData) missionentity.Re 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...), - SuggestedTags: tags, - SimilarAccounts: accounts, - BenchmarkNotes: data.BenchmarkNotes, + 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, } } diff --git a/backend/internal/logic/copy_mission/matrix_guard.go b/backend/internal/logic/copy_mission/matrix_guard.go new file mode 100644 index 0000000..e2aa93c --- /dev/null +++ b/backend/internal/logic/copy_mission/matrix_guard.go @@ -0,0 +1,42 @@ +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 +} \ No newline at end of file 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 new file mode 100644 index 0000000..f13a687 --- /dev/null +++ b/backend/internal/logic/copy_mission/patch_copy_mission_audience_sample_logic.go @@ -0,0 +1,49 @@ +// 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" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PatchCopyMissionAudienceSampleLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPatchCopyMissionAudienceSampleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchCopyMissionAudienceSampleLogic { + return &PatchCopyMissionAudienceSampleLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +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 +} 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 new file mode 100644 index 0000000..a6f48d0 --- /dev/null +++ b/backend/internal/logic/copy_mission/patch_copy_mission_graph_nodes_logic.go @@ -0,0 +1,101 @@ +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" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PatchCopyMissionGraphNodesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPatchCopyMissionGraphNodesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchCopyMissionGraphNodesLogic { + return &PatchCopyMissionGraphNodesLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +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 + } + + 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 +} \ No newline at end of file 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 new file mode 100644 index 0000000..cf59b9d --- /dev/null +++ b/backend/internal/logic/copy_mission/patch_copy_mission_similar_account_logic.go @@ -0,0 +1,69 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +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" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PatchCopyMissionSimilarAccountLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPatchCopyMissionSimilarAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchCopyMissionSimilarAccountLogic { + return &PatchCopyMissionSimilarAccountLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +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 +} diff --git a/backend/internal/logic/copy_mission/research_map_entity.go b/backend/internal/logic/copy_mission/research_map_entity.go new file mode 100644 index 0000000..780b201 --- /dev/null +++ b/backend/internal/logic/copy_mission/research_map_entity.go @@ -0,0 +1,78 @@ +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, + } +} \ No newline at end of file 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 8614b29..ec491ec 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 @@ -35,6 +35,9 @@ func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob( 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 + } payload := map[string]any{ "tenant_id": tenantID, 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 30112a3..9d1cd25 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 @@ -45,6 +45,9 @@ func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob( 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 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 d54dbc1..2315e07 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 @@ -35,12 +35,13 @@ func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob( if err != nil { return nil, err } - if mission.Status != string(missionentity.StatusScanned) && + if mission.Status != string(missionentity.StatusMapped) && + mission.Status != string(missionentity.StatusScanned) && mission.Status != string(missionentity.StatusDrafted) { - return nil, app.For(code.Persona).ResInvalidState("請先完成海巡再產出內容矩陣") + return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣") } - if len(mission.SelectedTags) == 0 { - return nil, app.For(code.Persona).InputMissingRequired("請先選擇海巡標籤") + if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { + return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖") } persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) @@ -50,6 +51,9 @@ func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob( 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 { @@ -60,6 +64,8 @@ func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob( } payload := map[string]any{ + "tenant_id": tenantID, + "owner_uid": uid, "persona_id": personaID, "copy_mission_id": missionID, "count": count, diff --git a/backend/internal/logic/member/get_member_capabilities_logic.go b/backend/internal/logic/member/get_member_capabilities_logic.go new file mode 100644 index 0000000..99d2f13 --- /dev/null +++ b/backend/internal/logic/member/get_member_capabilities_logic.go @@ -0,0 +1,51 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetMemberCapabilitiesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetMemberCapabilitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMemberCapabilitiesLogic { + return &GetMemberCapabilitiesLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *GetMemberCapabilitiesLogic) GetMemberCapabilities() (*types.MemberCapabilitiesData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + caps, err := l.svcCtx.ThreadsAccount.ResolveMemberCapabilities(l.ctx, tenantID, uid, research) + if err != nil { + return nil, err + } + return &types.MemberCapabilitiesData{ + DiscoverReady: caps.DiscoverReady, + AiReady: caps.AiReady, + PublishReady: caps.PublishReady, + DiscoverHint: caps.DiscoverHint, + AiHint: caps.AiHint, + PublishHint: caps.PublishHint, + ActiveThreadsAccountId: caps.ActiveThreadsAccount, + }, nil +} diff --git a/backend/internal/logic/persona/delete_persona_copy_draft_logic.go b/backend/internal/logic/persona/delete_persona_copy_draft_logic.go new file mode 100644 index 0000000..ee99435 --- /dev/null +++ b/backend/internal/logic/persona/delete_persona_copy_draft_logic.go @@ -0,0 +1,37 @@ +package persona + +import ( + "context" + "strings" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +type DeletePersonaCopyDraftLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeletePersonaCopyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePersonaCopyDraftLogic { + return &DeletePersonaCopyDraftLogic{ctx: ctx, svcCtx: svcCtx} +} + +func (l *DeletePersonaCopyDraftLogic) DeletePersonaCopyDraft(req *types.CopyDraftPath) (*types.DeleteCopyDraftData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + personaID := strings.TrimSpace(req.ID) + draftID := strings.TrimSpace(req.DraftID) + if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil { + return nil, err + } + if err := l.svcCtx.CopyDraft.Delete(l.ctx, tenantID, uid, personaID, draftID); err != nil { + return nil, err + } + return &types.DeleteCopyDraftData{ + DraftID: draftID, + Message: "草稿已刪除", + }, nil +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go b/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go index 1b9e3ec..41556bb 100644 --- a/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go +++ b/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go @@ -55,4 +55,4 @@ func (l *ListThreadsAccountAiProviderModelsLogic) ListThreadsAccountAiProviderMo Streams: result.Streams, Error: result.Error, }, nil -} \ No newline at end of file +} diff --git a/backend/internal/model/copy_draft/domain/entity/draft.go b/backend/internal/model/copy_draft/domain/entity/draft.go index ac766b3..75e6eb8 100644 --- a/backend/internal/model/copy_draft/domain/entity/draft.go +++ b/backend/internal/model/copy_draft/domain/entity/draft.go @@ -16,6 +16,7 @@ type CopyDraft struct { CopyMissionID string `bson:"copy_mission_id,omitempty"` ScanPostID string `bson:"scan_post_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"` diff --git a/backend/internal/model/copy_draft/domain/repository/repository.go b/backend/internal/model/copy_draft/domain/repository/repository.go index 1780551..2dc27af 100644 --- a/backend/internal/model/copy_draft/domain/repository/repository.go +++ b/backend/internal/model/copy_draft/domain/repository/repository.go @@ -12,8 +12,15 @@ type Repository interface { CreateMany(ctx context.Context, drafts []*entity.CopyDraft) error Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*entity.CopyDraft, error) Update(ctx context.Context, tenantID, ownerUID, personaID, draftID string, patch map[string]interface{}) (*entity.CopyDraft, error) + Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error + DeleteMany(ctx context.Context, tenantID, ownerUID, personaID, missionID, draftType string, draftIDs []string) (int64, error) DeleteByMissionAndType(ctx context.Context, tenantID, ownerUID, personaID, missionID, draftType string) error DeleteByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.CopyDraft, error) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]entity.CopyDraft, error) + ReplaceMissionMatrix( + ctx context.Context, + tenantID, ownerUID, personaID, missionID string, + drafts []*entity.CopyDraft, + ) error } diff --git a/backend/internal/model/copy_draft/domain/usecase/usecase.go b/backend/internal/model/copy_draft/domain/usecase/usecase.go index 0e33618..386601f 100644 --- a/backend/internal/model/copy_draft/domain/usecase/usecase.go +++ b/backend/internal/model/copy_draft/domain/usecase/usecase.go @@ -71,6 +71,14 @@ type UpdateRequest struct { Patch CopyDraftPatch } +type DeleteMissionMatrixDraftsRequest struct { + TenantID string + OwnerUID string + PersonaID string + MissionID string + DraftIDs []string +} + type UseCase interface { Create(ctx context.Context, req CreateRequest) (*CopyDraftSummary, error) CreateMany(ctx context.Context, req CreateManyRequest) ([]CopyDraftSummary, error) @@ -80,5 +88,7 @@ type UseCase interface { MarkPublished(ctx context.Context, req MarkPublishedRequest) (*CopyDraftSummary, error) List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]CopyDraftSummary, 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) ClearByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error } diff --git a/backend/internal/model/copy_draft/repository/mongo.go b/backend/internal/model/copy_draft/repository/mongo.go index bbff516..24929e5 100644 --- a/backend/internal/model/copy_draft/repository/mongo.go +++ b/backend/internal/model/copy_draft/repository/mongo.go @@ -12,6 +12,7 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" + "go.mongodb.org/mongo-driver/mongo/writeconcern" ) type mongoRepository struct { @@ -159,6 +160,61 @@ func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, person return &out, nil } +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error { + if r.collection == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + draftID = strings.TrimSpace(draftID) + if draftID == "" { + return app.For(code.Persona).InputMissingRequired("draft_id is required") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["_id"] = draftID + res, err := r.collection.DeleteOne(ctx, filter) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return app.For(code.Persona).ResNotFound("copy draft not found") + } + return nil +} + +func (r *mongoRepository) DeleteMany( + ctx context.Context, + tenantID, ownerUID, personaID, missionID, draftType string, + draftIDs []string, +) (int64, error) { + if r.collection == nil { + return 0, app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + missionID = strings.TrimSpace(missionID) + if missionID == "" { + return 0, app.For(code.Persona).InputMissingRequired("copy_mission_id is required") + } + filter := personaFilter(tenantID, ownerUID, personaID) + filter["copy_mission_id"] = missionID + if draftType = strings.TrimSpace(draftType); draftType != "" { + filter["draft_type"] = draftType + } + ids := make([]string, 0, len(draftIDs)) + for _, id := range draftIDs { + id = strings.TrimSpace(id) + if id == "" { + continue + } + ids = append(ids, id) + } + if len(ids) > 0 { + filter["_id"] = bson.M{"$in": ids} + } + res, err := r.collection.DeleteMany(ctx, filter) + if err != nil { + return 0, err + } + return res.DeletedCount, nil +} + func (r *mongoRepository) DeleteByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error { if r.collection == nil { return app.For(code.Persona).DBUnavailable("Mongo is not configured") @@ -189,6 +245,99 @@ func (r *mongoRepository) DeleteByMissionAndType(ctx context.Context, tenantID, return err } +func matrixDraftFilter(tenantID, ownerUID, personaID, missionID string) bson.M { + filter := personaFilter(tenantID, ownerUID, personaID) + filter["copy_mission_id"] = strings.TrimSpace(missionID) + filter["draft_type"] = entity.DraftTypeMatrix + return filter +} + +func (r *mongoRepository) ReplaceMissionMatrix( + ctx context.Context, + tenantID, ownerUID, personaID, missionID string, + drafts []*entity.CopyDraft, +) error { + if r.collection == nil { + return app.For(code.Persona).DBUnavailable("Mongo is not configured") + } + missionID = strings.TrimSpace(missionID) + if missionID == "" { + return app.For(code.Persona).InputMissingRequired("copy_mission_id is required") + } + if err := r.replaceMissionMatrixWithTransaction(ctx, tenantID, ownerUID, personaID, missionID, drafts); err == nil { + return nil + } + return r.replaceMissionMatrixInsertFirst(ctx, tenantID, ownerUID, personaID, missionID, drafts) +} + +func (r *mongoRepository) replaceMissionMatrixWithTransaction( + ctx context.Context, + tenantID, ownerUID, personaID, missionID string, + drafts []*entity.CopyDraft, +) error { + client := r.collection.Database().Client() + session, err := client.StartSession() + if err != nil { + return err + } + defer session.EndSession(ctx) + + wc := writeconcern.Majority() + txnOpts := options.Transaction().SetWriteConcern(wc) + _, err = session.WithTransaction(ctx, func(sessCtx mongo.SessionContext) (any, error) { + filter := matrixDraftFilter(tenantID, ownerUID, personaID, missionID) + if _, err := r.collection.DeleteMany(sessCtx, filter); err != nil { + return nil, err + } + if len(drafts) == 0 { + return nil, nil + } + docs := make([]any, 0, len(drafts)) + for _, draft := range drafts { + if draft == nil { + continue + } + docs = append(docs, draft) + } + if len(docs) == 0 { + return nil, nil + } + if _, err := r.collection.InsertMany(sessCtx, docs); err != nil { + return nil, err + } + return nil, nil + }, txnOpts) + return err +} + +func (r *mongoRepository) replaceMissionMatrixInsertFirst( + ctx context.Context, + tenantID, ownerUID, personaID, missionID string, + drafts []*entity.CopyDraft, +) error { + batchID := "" + if len(drafts) > 0 { + batchID = strings.TrimSpace(drafts[0].MatrixBatchID) + } + if batchID == "" { + filter := matrixDraftFilter(tenantID, ownerUID, personaID, missionID) + if _, err := r.collection.DeleteMany(ctx, filter); err != nil { + return err + } + if len(drafts) == 0 { + return nil + } + return r.CreateMany(ctx, drafts) + } + if err := r.CreateMany(ctx, drafts); err != nil { + return err + } + filter := matrixDraftFilter(tenantID, ownerUID, personaID, missionID) + filter["matrix_batch_id"] = bson.M{"$ne": batchID} + _, err := r.collection.DeleteMany(ctx, filter) + return err +} + 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 c98eb6c..8a65d96 100644 --- a/backend/internal/model/copy_draft/usecase/usecase.go +++ b/backend/internal/model/copy_draft/usecase/usecase.go @@ -5,21 +5,24 @@ import ( "strings" "haixun-backend/internal/library/clock" + libcopy "haixun-backend/internal/library/copymission" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "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" + goredis "github.com/redis/go-redis/v9" "github.com/google/uuid" ) type copyDraftUseCase struct { - repo domrepo.Repository + repo domrepo.Repository + redis *goredis.Client } -func NewUseCase(repo domrepo.Repository) domusecase.UseCase { - return ©DraftUseCase{repo: repo} +func NewUseCase(repo domrepo.Repository, redis *goredis.Client) domusecase.UseCase { + return ©DraftUseCase{repo: repo, redis: redis} } func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.CopyDraftSummary, error) { @@ -202,15 +205,121 @@ func (u *copyDraftUseCase) ReplaceMissionMatrix( if missionID == "" { return nil, app.For(code.Persona).InputMissingRequired("copy_mission_id is required") } - if err := u.repo.DeleteByMissionAndType(ctx, tenantID, ownerUID, personaID, missionID, entity.DraftTypeMatrix); err != nil { + var out []domusecase.CopyDraftSummary + err := libcopy.WithMatrixReplaceLock(ctx, u.redis, tenantID, missionID, ownerUID, func() error { + items, buildErr := buildMatrixDraftEntities(tenantID, ownerUID, personaID, missionID, drafts) + if buildErr != nil { + return buildErr + } + if replaceErr := u.repo.ReplaceMissionMatrix(ctx, tenantID, ownerUID, personaID, missionID, items); replaceErr != nil { + return replaceErr + } + out = summariesFromEntities(items) + return nil + }) + if err != nil { + if strings.Contains(err.Error(), "already in progress") { + return nil, app.For(code.Persona).ResInvalidState("內容矩陣正在寫入中,請稍後再試") + } return nil, err } - return u.CreateMany(ctx, domusecase.CreateManyRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - PersonaID: personaID, - Drafts: drafts, - }) + return out, nil +} + +func buildMatrixDraftEntities( + tenantID, ownerUID, personaID, missionID string, + drafts []domusecase.CreateRequest, +) ([]*entity.CopyDraft, error) { + if len(drafts) == 0 { + return nil, nil + } + batchID := uuid.NewString() + now := clock.NowUnixNano() + items := make([]*entity.CopyDraft, 0, len(drafts)) + for idx, draftReq := range drafts { + text := strings.TrimSpace(draftReq.Text) + if text == "" { + continue + } + sortOrder := draftReq.SortOrder + if sortOrder == 0 { + sortOrder = idx + 1 + } + items = append(items, &entity.CopyDraft{ + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + PersonaID: personaID, + CopyMissionID: missionID, + ScanPostID: strings.TrimSpace(draftReq.ScanPostID), + DraftType: entity.DraftTypeMatrix, + MatrixBatchID: batchID, + 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: now, + }) + } + if len(items) == 0 { + return nil, app.For(code.Persona).InputMissingRequired("draft text is required") + } + return items, nil +} + +func summariesFromEntities(items []*entity.CopyDraft) []domusecase.CopyDraftSummary { + out := make([]domusecase.CopyDraftSummary, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + out = append(out, toSummary(*item)) + } + return out +} + +func (u *copyDraftUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error { + if err := requireActor(tenantID, ownerUID, personaID); err != nil { + return err + } + draftID = strings.TrimSpace(draftID) + if draftID == "" { + return app.For(code.Persona).InputMissingRequired("draft_id is required") + } + return u.repo.Delete(ctx, tenantID, ownerUID, personaID, draftID) +} + +func (u *copyDraftUseCase) DeleteMissionMatrixDrafts( + ctx context.Context, + req domusecase.DeleteMissionMatrixDraftsRequest, +) (int, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return 0, err + } + missionID := strings.TrimSpace(req.MissionID) + if missionID == "" { + return 0, app.For(code.Persona).InputMissingRequired("copy_mission_id is required") + } + count, err := u.repo.DeleteMany( + ctx, + req.TenantID, + req.OwnerUID, + req.PersonaID, + missionID, + entity.DraftTypeMatrix, + req.DraftIDs, + ) + if err != nil { + return 0, err + } + if len(req.DraftIDs) > 0 && count == 0 { + return 0, app.For(code.Persona).ResNotFound("找不到要刪除的矩陣草稿") + } + return int(count), nil } func (u *copyDraftUseCase) ClearByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error { diff --git a/backend/internal/model/copy_mission/domain/entity/mission.go b/backend/internal/model/copy_mission/domain/entity/mission.go index 0e70507..5f41c2c 100644 --- a/backend/internal/model/copy_mission/domain/entity/mission.go +++ b/backend/internal/model/copy_mission/domain/entity/mission.go @@ -20,35 +20,74 @@ type SuggestedTag struct { SearchType string `bson:"search_type,omitempty" json:"search_type,omitempty"` } +const ( + SimilarAccountStatusRecommended = "recommended" + SimilarAccountStatusPinned = "pinned" + SimilarAccountStatusExcluded = "excluded" + SimilarAccountStatusPromoted = "promoted" +) + +const ( + AudienceSampleStatusPinned = "pinned" + AudienceSampleStatusExcluded = "excluded" +) + type SimilarAccount struct { - Username string `bson:"username" json:"username"` - Reason string `bson:"reason,omitempty" json:"reason,omitempty"` - Source string `bson:"source,omitempty" json:"source,omitempty"` - Confidence string `bson:"confidence,omitempty" json:"confidence,omitempty"` - ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"` - AuthorVerified bool `bson:"author_verified,omitempty" json:"author_verified,omitempty"` - FollowerCount int `bson:"follower_count,omitempty" json:"follower_count,omitempty"` - EngagementScore int `bson:"engagement_score,omitempty" json:"engagement_score,omitempty"` - LikeCount int `bson:"like_count,omitempty" json:"like_count,omitempty"` - ReplyCount int `bson:"reply_count,omitempty" json:"reply_count,omitempty"` - PostCount int `bson:"post_count,omitempty" json:"post_count,omitempty"` + Username string `bson:"username" json:"username"` + Reason string `bson:"reason,omitempty" json:"reason,omitempty"` + Source string `bson:"source,omitempty" json:"source,omitempty"` + MatchedSource []string `bson:"matched_source,omitempty" json:"matched_source,omitempty"` + Confidence string `bson:"confidence,omitempty" json:"confidence,omitempty"` + Status string `bson:"status,omitempty" json:"status,omitempty"` + TopicRelevance float64 `bson:"topic_relevance,omitempty" json:"topic_relevance,omitempty"` + LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"` + ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"` + AuthorVerified bool `bson:"author_verified,omitempty" json:"author_verified,omitempty"` + FollowerCount int `bson:"follower_count,omitempty" json:"follower_count,omitempty"` + EngagementScore int `bson:"engagement_score,omitempty" json:"engagement_score,omitempty"` + LikeCount int `bson:"like_count,omitempty" json:"like_count,omitempty"` + ReplyCount int `bson:"reply_count,omitempty" json:"reply_count,omitempty"` + PostCount int `bson:"post_count,omitempty" json:"post_count,omitempty"` +} + +type ResearchItem struct { + Title string `bson:"title,omitempty" json:"title,omitempty"` + URL string `bson:"url,omitempty" json:"url,omitempty"` + Snippet string `bson:"snippet,omitempty" json:"snippet,omitempty"` + Query string `bson:"query,omitempty" json:"query,omitempty"` +} + +type AudienceSample struct { + Username string `bson:"username" json:"username"` + SamplePostID string `bson:"sample_post_id,omitempty" json:"sample_post_id,omitempty"` + SampleText string `bson:"sample_text,omitempty" json:"sample_text,omitempty"` + ReplyLikeCount int `bson:"reply_like_count,omitempty" json:"reply_like_count,omitempty"` + Appearances int `bson:"appearances,omitempty" json:"appearances,omitempty"` + FirstSeenAt int64 `bson:"first_seen_at" json:"first_seen_at"` + LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"` + Status string `bson:"status,omitempty" json:"status,omitempty"` } type ResearchMap struct { - AudienceSummary string `bson:"audience_summary,omitempty" json:"audience_summary,omitempty"` - ContentGoal string `bson:"content_goal,omitempty" json:"content_goal,omitempty"` - Questions []string `bson:"questions,omitempty" json:"questions,omitempty"` - Pillars []string `bson:"pillars,omitempty" json:"pillars,omitempty"` - Exclusions []string `bson:"exclusions,omitempty" json:"exclusions,omitempty"` - SuggestedTags []SuggestedTag `bson:"suggested_tags,omitempty" json:"suggested_tags,omitempty"` - SimilarAccounts []SimilarAccount `bson:"similar_accounts,omitempty" json:"similar_accounts,omitempty"` - BenchmarkNotes string `bson:"benchmark_notes,omitempty" json:"benchmark_notes,omitempty"` + AudienceSummary string `bson:"audience_summary,omitempty" json:"audience_summary,omitempty"` + ContentGoal string `bson:"content_goal,omitempty" json:"content_goal,omitempty"` + Questions []string `bson:"questions,omitempty" json:"questions,omitempty"` + Pillars []string `bson:"pillars,omitempty" json:"pillars,omitempty"` + Exclusions []string `bson:"exclusions,omitempty" json:"exclusions,omitempty"` + KnowledgeItems []string `bson:"knowledge_items,omitempty" json:"knowledge_items,omitempty"` + SelectedKnowledgeItems []string `bson:"selected_knowledge_items,omitempty" json:"selected_knowledge_items,omitempty"` + ResearchItems []ResearchItem `bson:"research_items,omitempty" json:"research_items,omitempty"` + SuggestedTags []SuggestedTag `bson:"suggested_tags,omitempty" json:"suggested_tags,omitempty"` + SimilarAccounts []SimilarAccount `bson:"similar_accounts,omitempty" json:"similar_accounts,omitempty"` + AudienceSamples []AudienceSample `bson:"audience_samples,omitempty" json:"audience_samples,omitempty"` + BenchmarkNotes string `bson:"benchmark_notes,omitempty" json:"benchmark_notes,omitempty"` } func (m ResearchMap) IsEmpty() bool { return m.AudienceSummary == "" && m.ContentGoal == "" && len(m.Questions) == 0 && + len(m.KnowledgeItems) == 0 && len(m.SuggestedTags) == 0 } diff --git a/backend/internal/model/copy_mission/domain/usecase/usecase.go b/backend/internal/model/copy_mission/domain/usecase/usecase.go index 7db6646..8b41ec1 100644 --- a/backend/internal/model/copy_mission/domain/usecase/usecase.go +++ b/backend/internal/model/copy_mission/domain/usecase/usecase.go @@ -6,6 +6,13 @@ import ( "haixun-backend/internal/model/copy_mission/domain/entity" ) +type ResearchItemSummary struct { + Title string + URL string + Snippet string + Query string +} + type SuggestedTagSummary struct { Tag string Reason string @@ -17,7 +24,11 @@ type SimilarAccountSummary struct { Username string Reason string Source string + MatchedSource []string Confidence string + Status string + TopicRelevance float64 + LastSeenAt int64 ProfileURL string AuthorVerified bool FollowerCount int @@ -27,15 +38,30 @@ type SimilarAccountSummary struct { PostCount int } +type AudienceSampleSummary struct { + Username string + SamplePostID string + SampleText string + ReplyLikeCount int + Appearances int + FirstSeenAt int64 + LastSeenAt int64 + Status string +} + type ResearchMapSummary struct { - AudienceSummary string - ContentGoal string - Questions []string - Pillars []string - Exclusions []string - SuggestedTags []SuggestedTagSummary - SimilarAccounts []SimilarAccountSummary - BenchmarkNotes string + AudienceSummary string + ContentGoal string + Questions []string + Pillars []string + Exclusions []string + KnowledgeItems []string + SelectedKnowledgeItems []string + ResearchItems []ResearchItemSummary + SuggestedTags []SuggestedTagSummary + SimilarAccounts []SimilarAccountSummary + AudienceSamples []AudienceSampleSummary + BenchmarkNotes string } type MissionSummary struct { @@ -64,23 +90,27 @@ type CreateRequest struct { } type MissionPatch struct { - Label *string - SeedQuery *string - Brief *string - AudienceSummary *string - ContentGoal *string - QuestionsSet bool - Questions []string - PillarsSet bool - Pillars []string - ExclusionsSet bool - Exclusions []string - BenchmarkNotes *string - SelectedTagsSet bool - SelectedTags []string - ResearchMap *entity.ResearchMap - LastScanJobID *string - Status *entity.Status + Label *string + SeedQuery *string + Brief *string + AudienceSummary *string + ContentGoal *string + QuestionsSet bool + Questions []string + PillarsSet bool + Pillars []string + ExclusionsSet bool + Exclusions []string + KnowledgeItemsSet bool + KnowledgeItems []string + SelectedKnowledgeItemsSet bool + SelectedKnowledgeItems []string + BenchmarkNotes *string + SelectedTagsSet bool + SelectedTags []string + ResearchMap *entity.ResearchMap + LastScanJobID *string + Status *entity.Status } type UpdateRequest struct { @@ -95,10 +125,30 @@ type ListResult struct { List []MissionSummary } +type PatchSimilarAccountRequest struct { + TenantID string + OwnerUID string + PersonaID string + MissionID string + Username string + Status string +} + +type PatchAudienceSampleRequest struct { + TenantID string + OwnerUID string + PersonaID string + MissionID string + Username string + Status string +} + type UseCase interface { List(ctx context.Context, tenantID, ownerUID, personaID string) (*ListResult, error) Create(ctx context.Context, req CreateRequest) (*MissionSummary, error) Get(ctx context.Context, tenantID, ownerUID, personaID, missionID string) (*MissionSummary, error) Update(ctx context.Context, req UpdateRequest) (*MissionSummary, error) + PatchSimilarAccount(ctx context.Context, req PatchSimilarAccountRequest) (*MissionSummary, error) + PatchAudienceSample(ctx context.Context, req PatchAudienceSampleRequest) (*MissionSummary, error) Delete(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error } diff --git a/backend/internal/model/copy_mission/usecase/usecase.go b/backend/internal/model/copy_mission/usecase/usecase.go index 77b896d..97b3e1b 100644 --- a/backend/internal/model/copy_mission/usecase/usecase.go +++ b/backend/internal/model/copy_mission/usecase/usecase.go @@ -129,6 +129,12 @@ func (u *missionUseCase) Update(ctx context.Context, req domusecase.UpdateReques if req.Patch.ExclusionsSet { patch["research_map.exclusions"] = cleanStringList(req.Patch.Exclusions) } + if req.Patch.KnowledgeItemsSet { + patch["research_map.knowledge_items"] = cleanStringList(req.Patch.KnowledgeItems) + } + if req.Patch.SelectedKnowledgeItemsSet { + patch["research_map.selected_knowledge_items"] = cleanStringList(req.Patch.SelectedKnowledgeItems) + } if req.Patch.BenchmarkNotes != nil { patch["research_map.benchmark_notes"] = strings.TrimSpace(*req.Patch.BenchmarkNotes) } @@ -152,6 +158,82 @@ func (u *missionUseCase) Update(ctx context.Context, req domusecase.UpdateReques return &summary, nil } +func (u *missionUseCase) PatchSimilarAccount(ctx context.Context, req domusecase.PatchSimilarAccountRequest) (*domusecase.MissionSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + status, err := validateSimilarAccountStatus(req.Status) + if err != nil { + return nil, err + } + username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@")) + if username == "" { + return nil, app.For(code.Facade).InputMissingRequired("username is required") + } + item, err := u.repo.FindByID(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID) + if err != nil { + return nil, err + } + found := false + accounts := append([]entity.SimilarAccount(nil), item.ResearchMap.SimilarAccounts...) + for i := range accounts { + if strings.EqualFold(strings.TrimSpace(accounts[i].Username), username) { + accounts[i].Status = status + found = true + break + } + } + if !found { + return nil, app.For(code.Facade).ResNotFound("similar account not found") + } + updated, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID, map[string]interface{}{ + "research_map.similar_accounts": accounts, + }) + if err != nil { + return nil, err + } + summary := toSummary(updated) + return &summary, nil +} + +func (u *missionUseCase) PatchAudienceSample(ctx context.Context, req domusecase.PatchAudienceSampleRequest) (*domusecase.MissionSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil { + return nil, err + } + status, err := validateAudienceSampleStatus(req.Status) + if err != nil { + return nil, err + } + username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@")) + if username == "" { + return nil, app.For(code.Facade).InputMissingRequired("username is required") + } + item, err := u.repo.FindByID(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID) + if err != nil { + return nil, err + } + found := false + samples := append([]entity.AudienceSample(nil), item.ResearchMap.AudienceSamples...) + for i := range samples { + if strings.EqualFold(strings.TrimSpace(samples[i].Username), username) { + samples[i].Status = status + found = true + break + } + } + if !found { + return nil, app.For(code.Facade).ResNotFound("audience sample not found") + } + updated, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID, map[string]interface{}{ + "research_map.audience_samples": samples, + }) + if err != nil { + return nil, err + } + summary := toSummary(updated) + return &summary, nil +} + func (u *missionUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error { if err := requireActor(tenantID, ownerUID, personaID); err != nil { return err @@ -184,11 +266,16 @@ func toSummary(item *entity.Mission) domusecase.MissionSummary { } accounts := make([]domusecase.SimilarAccountSummary, 0, len(item.ResearchMap.SimilarAccounts)) for _, acc := range item.ResearchMap.SimilarAccounts { + matched := append([]string(nil), acc.MatchedSource...) accounts = append(accounts, domusecase.SimilarAccountSummary{ Username: acc.Username, Reason: acc.Reason, Source: acc.Source, + MatchedSource: matched, Confidence: acc.Confidence, + Status: acc.Status, + TopicRelevance: acc.TopicRelevance, + LastSeenAt: acc.LastSeenAt, ProfileURL: acc.ProfileURL, AuthorVerified: acc.AuthorVerified, FollowerCount: acc.FollowerCount, @@ -198,13 +285,26 @@ func toSummary(item *entity.Mission) domusecase.MissionSummary { PostCount: acc.PostCount, }) } + samples := make([]domusecase.AudienceSampleSummary, 0, len(item.ResearchMap.AudienceSamples)) + for _, sample := range item.ResearchMap.AudienceSamples { + samples = append(samples, domusecase.AudienceSampleSummary{ + 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 domusecase.MissionSummary{ ID: item.ID, PersonaID: item.PersonaID, Label: item.Label, SeedQuery: item.SeedQuery, Brief: item.Brief, - ResearchMap: toMapSummary(item.ResearchMap, tags, accounts), + ResearchMap: toMapSummary(item.ResearchMap, tags, accounts, samples), SelectedTags: append([]string(nil), item.SelectedTags...), LastScanJobID: item.LastScanJobID, Status: string(item.Status), @@ -213,16 +313,59 @@ func toSummary(item *entity.Mission) domusecase.MissionSummary { } } -func toMapSummary(m entity.ResearchMap, tags []domusecase.SuggestedTagSummary, accounts []domusecase.SimilarAccountSummary) domusecase.ResearchMapSummary { +func toResearchItemSummaries(items []entity.ResearchItem) []domusecase.ResearchItemSummary { + out := make([]domusecase.ResearchItemSummary, 0, len(items)) + for _, item := range items { + out = append(out, domusecase.ResearchItemSummary{ + Title: item.Title, + URL: item.URL, + Snippet: item.Snippet, + Query: item.Query, + }) + } + return out +} + +func toMapSummary(m entity.ResearchMap, tags []domusecase.SuggestedTagSummary, accounts []domusecase.SimilarAccountSummary, samples []domusecase.AudienceSampleSummary) domusecase.ResearchMapSummary { return domusecase.ResearchMapSummary{ - AudienceSummary: m.AudienceSummary, - ContentGoal: m.ContentGoal, - Questions: append([]string(nil), m.Questions...), - Pillars: append([]string(nil), m.Pillars...), - Exclusions: append([]string(nil), m.Exclusions...), - SuggestedTags: tags, - SimilarAccounts: accounts, - BenchmarkNotes: m.BenchmarkNotes, + AudienceSummary: m.AudienceSummary, + ContentGoal: m.ContentGoal, + Questions: append([]string(nil), m.Questions...), + Pillars: append([]string(nil), m.Pillars...), + Exclusions: append([]string(nil), m.Exclusions...), + KnowledgeItems: append([]string(nil), m.KnowledgeItems...), + SelectedKnowledgeItems: append([]string(nil), m.SelectedKnowledgeItems...), + ResearchItems: toResearchItemSummaries(m.ResearchItems), + SuggestedTags: tags, + SimilarAccounts: accounts, + AudienceSamples: samples, + BenchmarkNotes: m.BenchmarkNotes, + } +} + +func validateSimilarAccountStatus(raw string) (string, error) { + status := strings.TrimSpace(raw) + if status == "" { + status = entity.SimilarAccountStatusRecommended + } + switch status { + case entity.SimilarAccountStatusRecommended, + entity.SimilarAccountStatusPinned, + entity.SimilarAccountStatusExcluded, + entity.SimilarAccountStatusPromoted: + return status, nil + default: + return "", app.For(code.Facade).ResInvalidState("similar account status not allowed") + } +} + +func validateAudienceSampleStatus(raw string) (string, error) { + status := strings.TrimSpace(raw) + switch status { + case "", entity.AudienceSampleStatusPinned, entity.AudienceSampleStatusExcluded: + return status, nil + default: + return "", app.For(code.Facade).ResInvalidState("audience sample status not allowed") } } diff --git a/backend/internal/model/job/domain/repository/queue.go b/backend/internal/model/job/domain/repository/queue.go index 03cf668..8f013ac 100644 --- a/backend/internal/model/job/domain/repository/queue.go +++ b/backend/internal/model/job/domain/repository/queue.go @@ -6,6 +6,7 @@ type QueueRepository interface { Enqueue(ctx context.Context, workerType, jobID string) error Dequeue(ctx context.Context, workerType string, timeoutSeconds int) (string, error) RemoveJob(ctx context.Context, workerType, jobID string) error + RemoveOneJob(ctx context.Context, workerType, jobID string) error SetCancelSignal(ctx context.Context, jobID, reason string) error GetCancelSignal(ctx context.Context, jobID string) (bool, string, error) ClearCancelSignal(ctx context.Context, jobID string) error diff --git a/backend/internal/model/job/domain/repository/run.go b/backend/internal/model/job/domain/repository/run.go index cd8d461..d403dcc 100644 --- a/backend/internal/model/job/domain/repository/run.go +++ b/backend/internal/model/job/domain/repository/run.go @@ -28,4 +28,5 @@ type RunRepository interface { FindPendingDue(ctx context.Context, now int64, limit int64) ([]*entity.Run, error) FindCancelRequestedBefore(ctx context.Context, before int64, limit int64) ([]*entity.Run, error) FindRunningTimedOut(ctx context.Context, now int64, limit int64) ([]*entity.Run, error) + FindQueuedWithLock(ctx context.Context, limit int64) ([]*entity.Run, error) } diff --git a/backend/internal/model/job/domain/usecase/job.go b/backend/internal/model/job/domain/usecase/job.go index 6be688b..59292a9 100644 --- a/backend/internal/model/job/domain/usecase/job.go +++ b/backend/internal/model/job/domain/usecase/job.go @@ -96,6 +96,7 @@ type UseCase interface { EnsurePlacementScanTemplate(ctx context.Context) error EnsureScanViralTemplate(ctx context.Context) error EnsureAnalyzeCopyMissionTemplate(ctx context.Context) error + EnsureExpandCopyMissionGraphTemplate(ctx context.Context) error EnsureGenerateCopyMatrixTemplate(ctx context.Context) error EnsureGenerateCopyDraftTemplate(ctx context.Context) error @@ -127,7 +128,9 @@ type UseCase interface { } type MaintenanceResult struct { - EnqueuedPending int - ReapedCancelGrace int - ReapedExpiredLocks int + EnqueuedPending int + ReapedCancelGrace int + ReapedExpiredLocks int + RepairedStuckClaims int + ReapedOrphanedLocks int } diff --git a/backend/internal/model/job/repository/mongo_run.go b/backend/internal/model/job/repository/mongo_run.go index 21c307c..ce582d1 100644 --- a/backend/internal/model/job/repository/mongo_run.go +++ b/backend/internal/model/job/repository/mongo_run.go @@ -243,9 +243,10 @@ func (r *mongoRunRepository) FindPendingDue(ctx context.Context, now int64, limi } cursor, err := r.collection.Find(ctx, bson.M{ "status": enum.RunStatusPending, - "scheduled_at": bson.M{ - "$lte": now, - "$ne": nil, + "$or": []bson.M{ + {"scheduled_at": bson.M{"$lte": now, "$ne": nil}}, + {"scheduled_at": nil}, + {"scheduled_at": bson.M{"$exists": false}}, }, }, options.Find().SetSort(bson.D{{Key: "scheduled_at", Value: 1}}).SetLimit(limit)) if err != nil { @@ -284,6 +285,31 @@ func (r *mongoRunRepository) FindCancelRequestedBefore(ctx context.Context, befo return items, nil } +func (r *mongoRunRepository) FindQueuedWithLock(ctx context.Context, limit int64) ([]*entity.Run, error) { + if r.collection == nil { + return nil, app.For(code.Job).DBUnavailable("Mongo is not configured") + } + if limit <= 0 { + limit = 50 + } + cursor, err := r.collection.Find(ctx, bson.M{ + "status": bson.M{"$in": []enum.RunStatus{enum.RunStatusPending, enum.RunStatusQueued}}, + "locked_by": bson.M{ + "$exists": true, + "$ne": "", + }, + }, options.Find().SetSort(bson.D{{Key: "update_at", Value: 1}}).SetLimit(limit)) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var items []*entity.Run + if err := cursor.All(ctx, &items); err != nil { + return nil, err + } + return items, nil +} + func (r *mongoRunRepository) FindRunningTimedOut(ctx context.Context, now int64, limit int64) ([]*entity.Run, error) { if r.collection == nil { return nil, app.For(code.Job).DBUnavailable("Mongo is not configured") diff --git a/backend/internal/model/job/repository/redis_queue.go b/backend/internal/model/job/repository/redis_queue.go index 6085e09..c792427 100644 --- a/backend/internal/model/job/repository/redis_queue.go +++ b/backend/internal/model/job/repository/redis_queue.go @@ -81,6 +81,13 @@ func (r *redisQueueRepository) RemoveJob(ctx context.Context, workerType, jobID return r.client.LRem(ctx, queueKey(workerType), 0, jobID).Err() } +func (r *redisQueueRepository) RemoveOneJob(ctx context.Context, workerType, jobID string) error { + if err := r.requireRedis(); err != nil { + return err + } + return r.client.LRem(ctx, queueKey(workerType), 1, jobID).Err() +} + func (r *redisQueueRepository) SetCancelSignal(ctx context.Context, jobID, reason string) error { if err := r.requireRedis(); err != nil { return err diff --git a/backend/internal/model/job/usecase/enqueue_run_test.go b/backend/internal/model/job/usecase/enqueue_run_test.go new file mode 100644 index 0000000..567d9ab --- /dev/null +++ b/backend/internal/model/job/usecase/enqueue_run_test.go @@ -0,0 +1,44 @@ +package usecase + +import ( + "context" + "testing" + + "haixun-backend/internal/model/job/domain/entity" + "haixun-backend/internal/model/job/domain/enum" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestEnqueueRun_DoesNotOverwriteRunningClaim(t *testing.T) { + ctx := context.Background() + template := expandGraphTemplate() + queue := newMemoryQueueRepo() + runs := newMemoryRunRepo(nil) + uc := testUseCaseFull(template, runs, nil, queue).(*jobUseCase) + + id := primitive.NewObjectID() + runs.run = &entity.Run{ + ID: id, + TemplateType: template.Type, + Status: enum.RunStatusRunning, + WorkerType: "go", + LockedBy: "worker-a", + } + + pending := &entity.Run{ + ID: id, + TemplateType: template.Type, + Status: enum.RunStatusPending, + WorkerType: "go", + } + updated, err := uc.enqueueRun(ctx, pending) + if err != nil { + t.Fatalf("enqueueRun() error = %v", err) + } + if updated.Status != enum.RunStatusRunning { + t.Fatalf("status = %s, want running", updated.Status) + } + if len(queue.queues["go"]) != 0 { + t.Fatalf("queue = %v, want duplicate enqueue removed", queue.queues["go"]) + } +} \ No newline at end of file diff --git a/backend/internal/model/job/usecase/helpers.go b/backend/internal/model/job/usecase/helpers.go index 7675ffc..7a35576 100644 --- a/backend/internal/model/job/usecase/helpers.go +++ b/backend/internal/model/job/usecase/helpers.go @@ -111,13 +111,34 @@ func (u *jobUseCase) enqueueRun(ctx context.Context, run *entity.Run) (*entity.R if run.ScheduledAt != nil && *run.ScheduledAt > clock.NowUnixNano() { return run, nil } + if run.Status == enum.RunStatusQueued { + if err := u.queue.Enqueue(ctx, run.WorkerType, jobID); err != nil { + return nil, err + } + return run, nil + } if err := u.queue.Enqueue(ctx, run.WorkerType, jobID); err != nil { return nil, err } fromStatus := string(run.Status) - run.Status = enum.RunStatusQueued - updated, err := u.runs.Update(ctx, run) + patch := *run + patch.Status = enum.RunStatusQueued + patch.LockedBy = "" + patch.LockedUntil = nil + patch.StartedAt = nil + updated, err := u.runs.UpdateIfStatus(ctx, &patch, []enum.RunStatus{enum.RunStatusPending}) if err != nil { + fresh, findErr := u.runs.FindByID(ctx, jobID) + if findErr == nil && fresh != nil { + switch fresh.Status { + case enum.RunStatusRunning: + _ = u.queue.RemoveOneJob(ctx, run.WorkerType, jobID) + return fresh, nil + case enum.RunStatusQueued: + return fresh, nil + } + } + _ = u.queue.RemoveOneJob(ctx, run.WorkerType, jobID) return nil, err } _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusQueued), "job enqueued", nil) diff --git a/backend/internal/model/job/usecase/maintenance.go b/backend/internal/model/job/usecase/maintenance.go index ca677a1..eef3e1f 100644 --- a/backend/internal/model/job/usecase/maintenance.go +++ b/backend/internal/model/job/usecase/maintenance.go @@ -51,6 +51,53 @@ func (u *jobUseCase) RunMaintenance(ctx context.Context) (domusecase.Maintenance result.ReapedCancelGrace++ } + stuckRuns, err := u.runs.FindQueuedWithLock(ctx, 50) + if err != nil { + return result, err + } + for _, run := range stuckRuns { + jobID := run.ID.Hex() + if run.StartedAt != nil && run.LockedUntil != nil && *run.LockedUntil > now { + fromStatus := string(run.Status) + patch := *run + patch.Status = enum.RunStatusRunning + if patch.Progress.Summary == "" || patch.Progress.Summary == "waiting for worker" { + patch.Progress.Summary = "worker claimed job (status repaired)" + } + updated, err := u.runs.UpdateIfStatus(ctx, &patch, []enum.RunStatus{ + enum.RunStatusPending, + enum.RunStatusQueued, + }) + if err != nil { + continue + } + _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusRunning), "repaired queued job with active worker lock", nil) + result.RepairedStuckClaims++ + _ = updated + continue + } + fromStatus := string(run.Status) + workerID := run.LockedBy + patch := *run + patch.LockedBy = "" + patch.LockedUntil = nil + patch.StartedAt = nil + patch.Progress.Summary = "waiting for worker" + patch.Progress.Percentage = 0 + if _, err := u.runs.UpdateIfStatus(ctx, &patch, []enum.RunStatus{ + enum.RunStatusPending, + enum.RunStatusQueued, + }); err != nil { + continue + } + _ = u.queue.ReleaseLock(ctx, jobID, workerID) + if err := u.queue.Enqueue(ctx, patch.WorkerType, jobID); err != nil { + continue + } + _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusQueued), "requeued orphaned lock", nil) + result.ReapedOrphanedLocks++ + } + expiredRuns, err := u.runs.FindRunningTimedOut(ctx, now, 50) if err != nil { return result, err diff --git a/backend/internal/model/job/usecase/maintenance_stuck_test.go b/backend/internal/model/job/usecase/maintenance_stuck_test.go new file mode 100644 index 0000000..6e9d97f --- /dev/null +++ b/backend/internal/model/job/usecase/maintenance_stuck_test.go @@ -0,0 +1,40 @@ +package usecase + +import ( + "context" + "testing" + "time" + + "haixun-backend/internal/library/clock" + "haixun-backend/internal/model/job/domain/entity" + "haixun-backend/internal/model/job/domain/enum" +) + +func TestRunMaintenance_RepairsQueuedWithActiveLock(t *testing.T) { + ctx := context.Background() + template := expandGraphTemplate() + queue := newMemoryQueueRepo() + runs := newMemoryRunRepo(nil) + started := clock.Now().UnixNano() + lockedUntil := clock.Now().Add(10 * time.Minute).UnixNano() + runs.run = &entity.Run{ + TemplateType: template.Type, + Status: enum.RunStatusQueued, + WorkerType: "go", + LockedBy: "worker-a", + StartedAt: &started, + LockedUntil: &lockedUntil, + } + uc := testUseCaseFull(template, runs, nil, queue) + + result, err := uc.RunMaintenance(ctx) + if err != nil { + t.Fatalf("RunMaintenance() error = %v", err) + } + if result.RepairedStuckClaims != 1 { + t.Fatalf("RepairedStuckClaims = %d, want 1", result.RepairedStuckClaims) + } + if runs.run.Status != enum.RunStatusRunning { + t.Fatalf("status = %s, want running", runs.run.Status) + } +} \ No newline at end of file diff --git a/backend/internal/model/job/usecase/test_mocks.go b/backend/internal/model/job/usecase/test_mocks.go index e9fc2e1..91885c0 100644 --- a/backend/internal/model/job/usecase/test_mocks.go +++ b/backend/internal/model/job/usecase/test_mocks.go @@ -3,6 +3,8 @@ package usecase import ( "context" "errors" + "strings" + "haixun-backend/internal/library/clock" "haixun-backend/internal/model/job/domain/entity" "haixun-backend/internal/model/job/domain/enum" @@ -160,6 +162,18 @@ func (m *memoryRunRepo) FindCancelRequestedBefore(_ context.Context, before int6 } return nil, nil } +func (m *memoryRunRepo) FindQueuedWithLock(_ context.Context, _ int64) ([]*entity.Run, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.run == nil || strings.TrimSpace(m.run.LockedBy) == "" { + return nil, nil + } + if m.run.Status != enum.RunStatusPending && m.run.Status != enum.RunStatusQueued { + return nil, nil + } + return []*entity.Run{m.run}, nil +} + func (m *memoryRunRepo) FindRunningTimedOut(_ context.Context, now int64, _ int64) ([]*entity.Run, error) { m.mu.Lock() defer m.mu.Unlock() @@ -312,6 +326,18 @@ func (m *memoryQueueRepo) RemoveJob(_ context.Context, workerType, jobID string) m.queues[workerType] = filtered return nil } +func (m *memoryQueueRepo) RemoveOneJob(_ context.Context, workerType, jobID string) error { + m.mu.Lock() + defer m.mu.Unlock() + items := m.queues[workerType] + for i, item := range items { + if item == jobID { + m.queues[workerType] = append(items[:i], items[i+1:]...) + return nil + } + } + return nil +} func (m *memoryQueueRepo) SetCancelSignal(_ context.Context, jobID, reason string) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/backend/internal/model/job/usecase/usecase.go b/backend/internal/model/job/usecase/usecase.go index 8aafe34..21c9562 100644 --- a/backend/internal/model/job/usecase/usecase.go +++ b/backend/internal/model/job/usecase/usecase.go @@ -19,7 +19,8 @@ const ( expandGraphTemplateType = "expand-graph" placementScanTemplateType = "placement-scan" scanViralTemplateType = "scan-viral" - analyzeCopyMissionTemplateType = "analyze-copy-mission" + analyzeCopyMissionTemplateType = "analyze-copy-mission" + expandCopyMissionGraphTemplateType = "expand-copy-mission-graph" generateCopyMatrixTemplateType = "generate-copy-matrix" generateCopyDraftTemplateType = "generate-copy-draft" style8DWorkerType = "node" @@ -89,6 +90,11 @@ func (u *jobUseCase) EnsureAnalyzeCopyMissionTemplate(ctx context.Context) error return err } +func (u *jobUseCase) EnsureExpandCopyMissionGraphTemplate(ctx context.Context) error { + _, err := u.templates.Upsert(ctx, expandCopyMissionGraphTemplate()) + return err +} + func (u *jobUseCase) EnsureGenerateCopyMatrixTemplate(ctx context.Context) error { _, err := u.templates.Upsert(ctx, generateCopyMatrixTemplate()) return err @@ -99,6 +105,32 @@ func (u *jobUseCase) EnsureGenerateCopyDraftTemplate(ctx context.Context) error return err } +func expandCopyMissionGraphTemplate() *entity.Template { + return &entity.Template{ + Type: expandCopyMissionGraphTemplateType, + Version: 1, + Name: "Expand Copy Mission Knowledge Graph", + Description: "Brave/LLM knowledge graph for copy ninja missions", + Enabled: true, + Repeatable: true, + ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope), + DedupeKeys: []string{"scope_id", "seed_query"}, + TimeoutSeconds: 600, + CancelPolicy: entity.CancelPolicy{ + Supported: true, + Mode: "cooperative", + GraceSeconds: 30, + }, + RetryPolicy: entity.RetryPolicy{ + MaxAttempts: 1, + BackoffSeconds: []int{}, + }, + Steps: []entity.TemplateStep{ + {ID: "expand_copy_knowledge", Name: "Expand copy mission knowledge", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 600, Cancelable: true}, + }, + } +} + func analyzeCopyMissionTemplate() *entity.Template { return &entity.Template{ Type: analyzeCopyMissionTemplateType, @@ -626,7 +658,12 @@ func (u *jobUseCase) ClaimNext(ctx context.Context, req domusecase.ClaimNextRequ }) if err != nil { _ = u.queue.ReleaseLock(ctx, jobID, workerID) - return nil, err + fresh, findErr := u.runs.FindByID(ctx, jobID) + if findErr == nil && fresh != nil && + (fresh.Status == enum.RunStatusPending || fresh.Status == enum.RunStatusQueued) { + _ = u.queue.Enqueue(ctx, workerType, jobID) + } + continue } _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusRunning), "worker claimed job", map[string]any{"worker_id": workerID}) return updated, nil @@ -737,6 +774,9 @@ func (u *jobUseCase) UpdateProgress(ctx context.Context, req domusecase.UpdatePr if strings.TrimSpace(req.WorkerID) != "" && run.LockedBy == req.WorkerID { lockUntil := clock.NowUnixNano() + clock.SecondsToNanos(600) run.LockedUntil = &lockUntil + if err := u.queue.RefreshLock(ctx, req.JobID, req.WorkerID, 600); err != nil { + return nil, err + } } return u.runs.UpdateIfLocked(ctx, run, req.WorkerID, []enum.RunStatus{enum.RunStatusRunning}) } diff --git a/backend/internal/model/knowledge_graph/domain/entity/graph.go b/backend/internal/model/knowledge_graph/domain/entity/graph.go index 6eb4825..33eeb29 100644 --- a/backend/internal/model/knowledge_graph/domain/entity/graph.go +++ b/backend/internal/model/knowledge_graph/domain/entity/graph.go @@ -12,6 +12,7 @@ type Graph struct { OwnerUID string `bson:"owner_uid"` BrandID string `bson:"brand_id"` TopicID string `bson:"topic_id,omitempty"` + CopyMissionID string `bson:"copy_mission_id,omitempty"` LegacyPersonaID string `bson:"persona_id,omitempty"` Seed string `bson:"seed"` Nodes []libkg.Node `bson:"nodes"` diff --git a/backend/internal/model/knowledge_graph/domain/repository/repository.go b/backend/internal/model/knowledge_graph/domain/repository/repository.go index 001ec48..9e40d92 100644 --- a/backend/internal/model/knowledge_graph/domain/repository/repository.go +++ b/backend/internal/model/knowledge_graph/domain/repository/repository.go @@ -15,5 +15,8 @@ type Repository interface { FindByTopic(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Graph, error) UpdateNodes(ctx context.Context, tenantID, ownerUID, brandID string, nodes []libkg.Node, painTagCount int) (*entity.Graph, error) UpdateNodesByTopic(ctx context.Context, tenantID, ownerUID, topicID string, nodes []libkg.Node, painTagCount int) (*entity.Graph, error) + UpsertByCopyMission(ctx context.Context, graph *entity.Graph) (*entity.Graph, error) + FindByCopyMission(ctx context.Context, tenantID, ownerUID, copyMissionID string) (*entity.Graph, error) + UpdateNodesByCopyMission(ctx context.Context, tenantID, ownerUID, copyMissionID string, nodes []libkg.Node, painTagCount int) (*entity.Graph, error) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error } diff --git a/backend/internal/model/knowledge_graph/domain/usecase/usecase.go b/backend/internal/model/knowledge_graph/domain/usecase/usecase.go index eb90f9b..c7aebcf 100644 --- a/backend/internal/model/knowledge_graph/domain/usecase/usecase.go +++ b/backend/internal/model/knowledge_graph/domain/usecase/usecase.go @@ -10,6 +10,7 @@ type GraphSummary struct { ID string BrandID string TopicID string + CopyMissionID string Seed string Nodes []libkg.Node Edges []libkg.Edge @@ -26,6 +27,7 @@ type UpsertRequest struct { OwnerUID string BrandID string TopicID string + CopyMissionID string Seed string Nodes []libkg.Node Edges []libkg.Edge @@ -45,17 +47,19 @@ type NodeUpdate struct { } type UpdateNodesRequest struct { - TenantID string - OwnerUID string - BrandID string - TopicID string - Updates []NodeUpdate + TenantID string + OwnerUID string + BrandID string + TopicID string + CopyMissionID string + Updates []NodeUpdate } type UseCase interface { Get(ctx context.Context, tenantID, ownerUID, brandID string) (*GraphSummary, error) // brandID enables legacy fallback when graph was stored under brand_id without topic_id. GetByTopic(ctx context.Context, tenantID, ownerUID, topicID, brandID string) (*GraphSummary, error) + GetByCopyMission(ctx context.Context, tenantID, ownerUID, copyMissionID string) (*GraphSummary, error) Upsert(ctx context.Context, req UpsertRequest) (*GraphSummary, error) UpdateNodes(ctx context.Context, req UpdateNodesRequest) (*GraphSummary, error) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error diff --git a/backend/internal/model/knowledge_graph/repository/mongo.go b/backend/internal/model/knowledge_graph/repository/mongo.go index 01abbbb..96dfdc4 100644 --- a/backend/internal/model/knowledge_graph/repository/mongo.go +++ b/backend/internal/model/knowledge_graph/repository/mongo.go @@ -36,9 +36,11 @@ func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { return libmongo.EnsureIndexes(ctx, r.collection, []mongo.IndexModel{ {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"brand_id": bson.M{"$gt": ""}, "topic_id": bson.M{"$in": []interface{}{nil, ""}}})}, {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "topic_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"topic_id": bson.M{"$gt": ""}})}, + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "copy_mission_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"copy_mission_id": bson.M{"$gt": ""}})}, {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"persona_id": bson.M{"$gt": ""}})}, {Keys: bson.D{{Key: "brand_id", Value: 1}, {Key: "update_at", Value: -1}}}, {Keys: bson.D{{Key: "topic_id", Value: 1}, {Key: "update_at", Value: -1}}}, + {Keys: bson.D{{Key: "copy_mission_id", Value: 1}, {Key: "update_at", Value: -1}}}, {Keys: bson.D{{Key: "persona_id", Value: 1}, {Key: "update_at", Value: -1}}}, }) } @@ -62,6 +64,14 @@ func topicOwnerFilter(tenantID, ownerUID, topicID string) bson.M { } } +func copyMissionOwnerFilter(tenantID, ownerUID, copyMissionID string) bson.M { + return bson.M{ + "tenant_id": tenantID, + "owner_uid": ownerUID, + "copy_mission_id": strings.TrimSpace(copyMissionID), + } +} + func (r *mongoRepository) UpsertByBrand(ctx context.Context, graph *entity.Graph) (*entity.Graph, error) { if r.collection == nil { return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") @@ -153,6 +163,97 @@ func (r *mongoRepository) UpsertByTopic(ctx context.Context, graph *entity.Graph return &out, nil } +func (r *mongoRepository) UpsertByCopyMission(ctx context.Context, graph *entity.Graph) (*entity.Graph, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + if graph == nil || strings.TrimSpace(graph.CopyMissionID) == "" { + return nil, app.For(code.Brand).InputMissingRequired("copy_mission_id is required") + } + now := clock.NowUnixNano() + graph.UpdateAt = now + if graph.CreateAt == 0 { + graph.CreateAt = now + } + if strings.TrimSpace(graph.ID) == "" { + graph.ID = uuid.NewString() + } + + filter := copyMissionOwnerFilter(graph.TenantID, graph.OwnerUID, graph.CopyMissionID) + update := bson.M{ + "$set": bson.M{ + "seed": graph.Seed, + "nodes": graph.Nodes, + "edges": graph.Edges, + "brave_sources": graph.BraveSources, + "expand_strategy": graph.ExpandStrategy, + "pain_tag_count": graph.PainTagCount, + "generated_at": graph.GeneratedAt, + "update_at": graph.UpdateAt, + "brand_id": graph.BrandID, + "copy_mission_id": graph.CopyMissionID, + }, + "$setOnInsert": bson.M{ + "_id": graph.ID, + "tenant_id": graph.TenantID, + "owner_uid": graph.OwnerUID, + "create_at": graph.CreateAt, + }, + } + opts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After) + var out entity.Graph + err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out) + if err != nil { + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) FindByCopyMission(ctx context.Context, tenantID, ownerUID, copyMissionID string) (*entity.Graph, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + var out entity.Graph + err := r.collection.FindOne(ctx, copyMissionOwnerFilter(tenantID, ownerUID, copyMissionID)).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + if err != nil { + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) UpdateNodesByCopyMission( + ctx context.Context, + tenantID, ownerUID, copyMissionID string, + nodes []libkg.Node, + painTagCount int, +) (*entity.Graph, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + now := clock.NowUnixNano() + var out entity.Graph + err := r.collection.FindOneAndUpdate( + ctx, + copyMissionOwnerFilter(tenantID, ownerUID, copyMissionID), + bson.M{"$set": bson.M{ + "nodes": nodes, + "pain_tag_count": painTagCount, + "update_at": now, + }}, + options.FindOneAndUpdate().SetReturnDocument(options.After), + ).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + if err != nil { + return nil, err + } + return &out, nil +} + func (r *mongoRepository) FindByTopic(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Graph, error) { if r.collection == nil { return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") diff --git a/backend/internal/model/knowledge_graph/usecase/usecase.go b/backend/internal/model/knowledge_graph/usecase/usecase.go index 06bba43..5680b2e 100644 --- a/backend/internal/model/knowledge_graph/usecase/usecase.go +++ b/backend/internal/model/knowledge_graph/usecase/usecase.go @@ -22,7 +22,7 @@ func NewUseCase(repo domrepo.Repository) domusecase.UseCase { } func (u *knowledgeGraphUseCase) Get(ctx context.Context, tenantID, ownerUID, brandID string) (*domusecase.GraphSummary, error) { - if err := requireScope(tenantID, ownerUID, brandID, ""); err != nil { + if err := requireScope(tenantID, ownerUID, brandID, "", ""); err != nil { return nil, err } item, err := u.repo.FindByBrand(ctx, tenantID, ownerUID, brandID) @@ -33,8 +33,20 @@ func (u *knowledgeGraphUseCase) Get(ctx context.Context, tenantID, ownerUID, bra return &summary, nil } +func (u *knowledgeGraphUseCase) GetByCopyMission(ctx context.Context, tenantID, ownerUID, copyMissionID string) (*domusecase.GraphSummary, error) { + if err := requireScope(tenantID, ownerUID, "", "", copyMissionID); err != nil { + return nil, err + } + item, err := u.repo.FindByCopyMission(ctx, tenantID, ownerUID, copyMissionID) + if err != nil { + return nil, err + } + summary := toSummary(item) + return &summary, nil +} + func (u *knowledgeGraphUseCase) GetByTopic(ctx context.Context, tenantID, ownerUID, topicID, brandID string) (*domusecase.GraphSummary, error) { - if err := requireScope(tenantID, ownerUID, "", topicID); err != nil { + if err := requireScope(tenantID, ownerUID, "", topicID, ""); err != nil { return nil, err } item, err := u.repo.FindByTopic(ctx, tenantID, ownerUID, topicID) @@ -69,7 +81,8 @@ func (u *knowledgeGraphUseCase) GetByTopic(ctx context.Context, tenantID, ownerU func (u *knowledgeGraphUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.GraphSummary, error) { topicID := strings.TrimSpace(req.TopicID) brandID := strings.TrimSpace(req.BrandID) - if err := requireScope(req.TenantID, req.OwnerUID, brandID, topicID); err != nil { + copyMissionID := strings.TrimSpace(req.CopyMissionID) + if err := requireScope(req.TenantID, req.OwnerUID, brandID, topicID, copyMissionID); err != nil { return nil, err } seed := strings.TrimSpace(req.Seed) @@ -81,6 +94,7 @@ func (u *knowledgeGraphUseCase) Upsert(ctx context.Context, req domusecase.Upser OwnerUID: req.OwnerUID, BrandID: brandID, TopicID: topicID, + CopyMissionID: copyMissionID, Seed: seed, Nodes: req.Nodes, Edges: req.Edges, @@ -93,9 +107,12 @@ func (u *knowledgeGraphUseCase) Upsert(ctx context.Context, req domusecase.Upser item *entity.Graph err error ) - if topicID != "" { + switch { + case copyMissionID != "": + item, err = u.repo.UpsertByCopyMission(ctx, graph) + case topicID != "": item, err = u.repo.UpsertByTopic(ctx, graph) - } else { + default: item, err = u.repo.UpsertByBrand(ctx, graph) } if err != nil { @@ -108,7 +125,8 @@ func (u *knowledgeGraphUseCase) Upsert(ctx context.Context, req domusecase.Upser func (u *knowledgeGraphUseCase) UpdateNodes(ctx context.Context, req domusecase.UpdateNodesRequest) (*domusecase.GraphSummary, error) { topicID := strings.TrimSpace(req.TopicID) brandID := strings.TrimSpace(req.BrandID) - if err := requireScope(req.TenantID, req.OwnerUID, brandID, topicID); err != nil { + copyMissionID := strings.TrimSpace(req.CopyMissionID) + if err := requireScope(req.TenantID, req.OwnerUID, brandID, topicID, copyMissionID); err != nil { return nil, err } if len(req.Updates) == 0 { @@ -118,9 +136,12 @@ func (u *knowledgeGraphUseCase) UpdateNodes(ctx context.Context, req domusecase. current *entity.Graph err error ) - if topicID != "" { + switch { + case copyMissionID != "": + current, err = u.repo.FindByCopyMission(ctx, req.TenantID, req.OwnerUID, copyMissionID) + case topicID != "": current, err = u.repo.FindByTopic(ctx, req.TenantID, req.OwnerUID, topicID) - } else { + default: current, err = u.repo.FindByBrand(ctx, req.TenantID, req.OwnerUID, brandID) } if err != nil { @@ -156,9 +177,12 @@ func (u *knowledgeGraphUseCase) UpdateNodes(ctx context.Context, req domusecase. } painCount := libkg.CountPainTagCandidates(nodes) var item *entity.Graph - if topicID != "" { + switch { + case copyMissionID != "": + item, err = u.repo.UpdateNodesByCopyMission(ctx, req.TenantID, req.OwnerUID, copyMissionID, nodes, painCount) + case topicID != "": item, err = u.repo.UpdateNodesByTopic(ctx, req.TenantID, req.OwnerUID, topicID, nodes, painCount) - } else { + default: item, err = u.repo.UpdateNodes(ctx, req.TenantID, req.OwnerUID, brandID, nodes, painCount) } if err != nil { @@ -169,18 +193,18 @@ func (u *knowledgeGraphUseCase) UpdateNodes(ctx context.Context, req domusecase. } func (u *knowledgeGraphUseCase) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error { - if err := requireScope(tenantID, ownerUID, brandID, topicID); err != nil { + if err := requireScope(tenantID, ownerUID, brandID, topicID, ""); err != nil { return err } return u.repo.AttachTopicID(ctx, tenantID, ownerUID, brandID, topicID) } -func requireScope(tenantID, ownerUID, brandID, topicID string) error { +func requireScope(tenantID, ownerUID, brandID, topicID, copyMissionID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return app.For(code.Brand).InputMissingRequired("tenant_id and uid are required") } - if strings.TrimSpace(topicID) == "" && strings.TrimSpace(brandID) == "" { - return app.For(code.Brand).InputMissingRequired("brand id or topic id is required") + if strings.TrimSpace(topicID) == "" && strings.TrimSpace(brandID) == "" && strings.TrimSpace(copyMissionID) == "" { + return app.For(code.Brand).InputMissingRequired("brand id, topic id, or copy mission id is required") } return nil } @@ -193,6 +217,7 @@ func toSummary(item *entity.Graph) domusecase.GraphSummary { ID: item.ID, BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID), TopicID: strings.TrimSpace(item.TopicID), + CopyMissionID: strings.TrimSpace(item.CopyMissionID), Seed: item.Seed, Nodes: item.Nodes, Edges: item.Edges, diff --git a/backend/internal/model/permission/usecase/usecase.go b/backend/internal/model/permission/usecase/usecase.go index 2cddd26..8fa0cd8 100644 --- a/backend/internal/model/permission/usecase/usecase.go +++ b/backend/internal/model/permission/usecase/usecase.go @@ -174,6 +174,7 @@ func defaultUserPermissionNames() []string { "auth.logout", "member.me.read", "member.me.update", + "member.capabilities.read", "member.placement_settings.read", "member.placement_settings.update", "permission.me.read", @@ -237,6 +238,7 @@ func defaultPermissions() []*entity.Permission { {Name: "member.me.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser}, {Name: "member.me.update", HTTPMethods: "PATCH", HTTPPath: "/api/v1/members/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser}, + {Name: "member.capabilities.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me/capabilities", Status: entity.StatusOpen, Type: entity.TypeFrontendUser}, {Name: "member.placement_settings.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me/placement-settings", Status: entity.StatusOpen, Type: entity.TypeFrontendUser}, {Name: "member.placement_settings.update", HTTPMethods: "PATCH", HTTPPath: "/api/v1/members/me/placement-settings", Status: entity.StatusOpen, Type: entity.TypeFrontendUser}, diff --git a/backend/internal/model/threads_account/domain/entity/account.go b/backend/internal/model/threads_account/domain/entity/account.go index b89417a..2e30028 100644 --- a/backend/internal/model/threads_account/domain/entity/account.go +++ b/backend/internal/model/threads_account/domain/entity/account.go @@ -9,15 +9,33 @@ const ( StatusDeleted Status = "deleted" ) -type Account struct { - ID string `bson:"_id"` - TenantID string `bson:"tenant_id"` - OwnerUID string `bson:"owner_uid"` - DisplayName string `bson:"display_name,omitempty"` - Username string `bson:"username,omitempty"` - ThreadsUserID string `bson:"threads_user_id,omitempty"` - PersonaID string `bson:"persona_id,omitempty"` - Status Status `bson:"status"` - CreateAt int64 `bson:"create_at"` - UpdateAt int64 `bson:"update_at"` +const ( + KnownAccountStatusExcluded = "excluded" +) + +// KnownAccountProfile stores cross-mission memory for a discovered username +// (Phase 4). Keyed by lowercase username inside Account.KnownAccounts. +type KnownAccountProfile struct { + FirstSeenAt int64 `bson:"first_seen_at" json:"first_seen_at"` + LastSeenAt int64 `bson:"last_seen_at" json:"last_seen_at"` + Missions []string `bson:"missions,omitempty" json:"missions,omitempty"` + PersonaIds []string `bson:"persona_ids,omitempty" json:"persona_ids,omitempty"` + Tags []string `bson:"tags,omitempty" json:"tags,omitempty"` + AvgEngagement float64 `bson:"avg_engagement,omitempty" json:"avg_engagement,omitempty"` + SeenCount int `bson:"seen_count,omitempty" json:"seen_count,omitempty"` + Status string `bson:"status,omitempty" json:"status,omitempty"` +} + +type Account struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + DisplayName string `bson:"display_name,omitempty"` + Username string `bson:"username,omitempty"` + ThreadsUserID string `bson:"threads_user_id,omitempty"` + PersonaID string `bson:"persona_id,omitempty"` + KnownAccounts map[string]KnownAccountProfile `bson:"known_accounts,omitempty" json:"known_accounts,omitempty"` + Status Status `bson:"status"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` } diff --git a/backend/internal/model/threads_account/domain/repository/repository.go b/backend/internal/model/threads_account/domain/repository/repository.go index 3c31e1e..b35ef75 100644 --- a/backend/internal/model/threads_account/domain/repository/repository.go +++ b/backend/internal/model/threads_account/domain/repository/repository.go @@ -13,6 +13,7 @@ type Repository interface { ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Account, error) UpdateShell(ctx context.Context, tenantID, ownerUID, accountID string, displayName, username, personaID *string) (*entity.Account, error) SoftDelete(ctx context.Context, tenantID, ownerUID, accountID string) error + SetKnownAccounts(ctx context.Context, tenantID, ownerUID, accountID string, known map[string]entity.KnownAccountProfile) error } type SecretsRepository interface { diff --git a/backend/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go index 4273378..7e50326 100644 --- a/backend/internal/model/threads_account/domain/usecase/usecase.go +++ b/backend/internal/model/threads_account/domain/usecase/usecase.go @@ -4,6 +4,8 @@ import ( "context" "haixun-backend/internal/library/placement" + missionentity "haixun-backend/internal/model/copy_mission/domain/entity" + "haixun-backend/internal/model/threads_account/domain/entity" ) type AccountSummary struct { @@ -130,6 +132,16 @@ type ListResult struct { ActiveAccountID string } +type UpsertKnownAccountsFromScanRequest struct { + TenantID string + OwnerUID string + AccountID string + MissionID string + PersonaID string + SelectedTags []string + SimilarAccounts []missionentity.SimilarAccount +} + type UseCase interface { List(ctx context.Context, tenantID, ownerUID string) (*ListResult, error) Create(ctx context.Context, req CreateRequest) (*AccountSummary, error) @@ -147,4 +159,7 @@ type UseCase interface { ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error) ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error) ResolveMemberThreadsPublishCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error) + ResolveMemberCapabilities(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberCapabilities, error) + GetKnownAccounts(ctx context.Context, tenantID, ownerUID, accountID string) (map[string]entity.KnownAccountProfile, error) + UpsertKnownAccountsFromScan(ctx context.Context, req UpsertKnownAccountsFromScanRequest) error } diff --git a/backend/internal/model/threads_account/repository/mongo.go b/backend/internal/model/threads_account/repository/mongo.go index 93f5acf..dd559e4 100644 --- a/backend/internal/model/threads_account/repository/mongo.go +++ b/backend/internal/model/threads_account/repository/mongo.go @@ -128,6 +128,39 @@ func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, ac return nil } +func (r *mongoRepository) SetKnownAccounts( + ctx context.Context, + tenantID, ownerUID, accountID string, + known map[string]entity.KnownAccountProfile, +) error { + if r.collection == nil { + return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") + } + if known == nil { + known = map[string]entity.KnownAccountProfile{} + } + res, err := r.collection.UpdateOne( + ctx, + bson.M{ + "_id": strings.TrimSpace(accountID), + "tenant_id": tenantID, + "owner_uid": ownerUID, + "status": entity.StatusOpen, + }, + bson.M{"$set": bson.M{ + "known_accounts": known, + "update_at": clock.NowUnixNano(), + }}, + ) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return app.For(code.ThreadsAccount).ResNotFound("threads account not found") + } + return nil +} + func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Account, error) { if r.collection == nil { return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured") diff --git a/backend/internal/model/threads_account/usecase/ai_credentials.go b/backend/internal/model/threads_account/usecase/ai_credentials.go index ec0bca9..6b1e1ea 100644 --- a/backend/internal/model/threads_account/usecase/ai_credentials.go +++ b/backend/internal/model/threads_account/usecase/ai_credentials.go @@ -105,7 +105,7 @@ func (u *threadsAccountUseCase) ResolveAiProviderAPIKey( } apiKey := strings.TrimSpace(stored.ApiKeys[provider]) if apiKey == "" { - return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 "+provider+" API key") + return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 " + provider + " API key") } return apiKey, nil } diff --git a/backend/internal/model/threads_account/usecase/connection_prefs_test.go b/backend/internal/model/threads_account/usecase/connection_prefs_test.go index a2d0e25..073aafe 100644 --- a/backend/internal/model/threads_account/usecase/connection_prefs_test.go +++ b/backend/internal/model/threads_account/usecase/connection_prefs_test.go @@ -44,4 +44,4 @@ func TestNormalizeConnectionPrefsKeepsCrawlerModeInFormalMode(t *testing.T) { func strPtr(v string) *string { return &v -} \ No newline at end of file +} diff --git a/backend/internal/model/threads_account/usecase/known_accounts.go b/backend/internal/model/threads_account/usecase/known_accounts.go new file mode 100644 index 0000000..1472569 --- /dev/null +++ b/backend/internal/model/threads_account/usecase/known_accounts.go @@ -0,0 +1,94 @@ +package usecase + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + "haixun-backend/internal/library/placement" + libviral "haixun-backend/internal/library/viral" + accountentity "haixun-backend/internal/model/threads_account/domain/entity" + domusecase "haixun-backend/internal/model/threads_account/domain/usecase" +) + +func (u *threadsAccountUseCase) GetKnownAccounts( + ctx context.Context, + tenantID, ownerUID, accountID string, +) (map[string]accountentity.KnownAccountProfile, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return map[string]accountentity.KnownAccountProfile{}, nil + } + account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) + if err != nil { + return nil, err + } + return cloneKnownAccounts(account.KnownAccounts), nil +} + +func (u *threadsAccountUseCase) UpsertKnownAccountsFromScan( + ctx context.Context, + req domusecase.UpsertKnownAccountsFromScanRequest, +) error { + if err := requireActor(req.TenantID, req.OwnerUID); err != nil { + return err + } + accountID := strings.TrimSpace(req.AccountID) + if accountID == "" || len(req.SimilarAccounts) == 0 { + return nil + } + account, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, accountID) + if err != nil { + return err + } + now := clock.NowUnixNano() + merged := libviral.MergeKnownAccountsFromScan( + cloneKnownAccounts(account.KnownAccounts), + req.SimilarAccounts, + strings.TrimSpace(req.MissionID), + strings.TrimSpace(req.PersonaID), + req.SelectedTags, + now, + ) + return u.repo.SetKnownAccounts(ctx, req.TenantID, req.OwnerUID, accountID, merged) +} + +func (u *threadsAccountUseCase) ResolveMemberCapabilities( + ctx context.Context, + tenantID, ownerUID string, + research placement.ResearchSettings, +) (placement.MemberCapabilities, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return placement.MemberCapabilities{}, err + } + member, err := u.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research) + if err != nil { + return placement.MemberCapabilities{}, err + } + _, aiErr := u.ResolveMemberAiCredential(ctx, tenantID, ownerUID) + _, publishErr := u.ResolveMemberThreadsPublishCredential(ctx, tenantID, ownerUID) + return placement.BuildMemberCapabilities(member, research, aiErr == nil, publishErr == nil), nil +} + +func cloneKnownAccounts(in map[string]accountentity.KnownAccountProfile) map[string]accountentity.KnownAccountProfile { + if len(in) == 0 { + return map[string]accountentity.KnownAccountProfile{} + } + out := make(map[string]accountentity.KnownAccountProfile, len(in)) + for key, item := range in { + out[key] = accountentity.KnownAccountProfile{ + FirstSeenAt: item.FirstSeenAt, + LastSeenAt: item.LastSeenAt, + Missions: append([]string(nil), item.Missions...), + PersonaIds: append([]string(nil), item.PersonaIds...), + Tags: append([]string(nil), item.Tags...), + AvgEngagement: item.AvgEngagement, + SeenCount: item.SeenCount, + Status: item.Status, + } + } + return out +} diff --git a/backend/internal/svc/service_context.go b/backend/internal/svc/service_context.go index f9311de..c5b0955 100644 --- a/backend/internal/svc/service_context.go +++ b/backend/internal/svc/service_context.go @@ -183,6 +183,9 @@ func NewServiceContext(c config.Config) *ServiceContext { 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) } @@ -200,7 +203,7 @@ func NewServiceContext(c config.Config) *ServiceContext { if err := copyDraftRepository.EnsureIndexes(ctx); err != nil { panic(err) } - copyDraftUseCase := copydraftusecase.NewUseCase(copyDraftRepository) + copyDraftUseCase := copydraftusecase.NewUseCase(copyDraftRepository, redisClient) scanPostRepository := scanpostrepo.NewMongoRepository(mongoClient.Database()) if err := scanPostRepository.EnsureIndexes(ctx); err != nil { @@ -339,12 +342,22 @@ func NewServiceContext(c config.Config) *ServiceContext { 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, }) diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index 6dac79a..397c0e6 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -191,6 +191,17 @@ type ContentMatrixRowData struct { Rationale string `json:"rationale"` } +type CopyAudienceSampleData struct { + Username string `json:"username"` + SamplePostId string `json:"sample_post_id,omitempty"` + SampleText string `json:"sample_text,omitempty"` + ReplyLikeCount int `json:"reply_like_count,omitempty"` + Appearances int `json:"appearances,omitempty"` + FirstSeenAt int64 `json:"first_seen_at"` + LastSeenAt int64 `json:"last_seen_at,omitempty"` + Status string `json:"status,omitempty"` +} + type CopyDraftData struct { ID string `json:"id"` PersonaID string `json:"persona_id"` @@ -241,6 +252,15 @@ type CopyMissionInspirationData struct { Message string `json:"message"` } +type CopyMissionInspirationHandlerReq struct { + PersonaCopyMissionsPath + CopyMissionInspirationReq +} + +type CopyMissionInspirationReq struct { + Keyword string `json:"keyword,optional"` +} + type CopyMissionInspirationSourceData struct { Query string `json:"query,omitempty"` Title string `json:"title,omitempty"` @@ -253,15 +273,26 @@ type CopyMissionPath struct { ID string `path:"id" validate:"required"` } +type CopyMissionResearchItemData struct { + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + Snippet string `json:"snippet,omitempty"` + Query string `json:"query,omitempty"` +} + type CopyMissionResearchMapData struct { - AudienceSummary string `json:"audience_summary,omitempty"` - ContentGoal string `json:"content_goal,omitempty"` - Questions []string `json:"questions,omitempty"` - Pillars []string `json:"pillars,omitempty"` - Exclusions []string `json:"exclusions,omitempty"` - SuggestedTags []CopySuggestedTagData `json:"suggested_tags,omitempty"` - SimilarAccounts []CopySimilarAccountData `json:"similar_accounts,omitempty"` - BenchmarkNotes string `json:"benchmark_notes,omitempty"` + AudienceSummary string `json:"audience_summary,omitempty"` + ContentGoal string `json:"content_goal,omitempty"` + Questions []string `json:"questions,omitempty"` + Pillars []string `json:"pillars,omitempty"` + Exclusions []string `json:"exclusions,omitempty"` + KnowledgeItems []string `json:"knowledge_items,omitempty"` + SelectedKnowledgeItems []string `json:"selected_knowledge_items,omitempty"` + ResearchItems []CopyMissionResearchItemData `json:"research_items,omitempty"` + SuggestedTags []CopySuggestedTagData `json:"suggested_tags,omitempty"` + SimilarAccounts []CopySimilarAccountData `json:"similar_accounts,omitempty"` + AudienceSamples []CopyAudienceSampleData `json:"audience_samples,omitempty"` + BenchmarkNotes string `json:"benchmark_notes,omitempty"` } type CopyMissionScanScheduleData struct { @@ -275,6 +306,12 @@ type CopyMissionScanScheduleData struct { LastRunAt int64 `json:"last_run_at,omitempty"` } +type CopyMissionSimilarAccountPath struct { + PersonaID string `path:"personaId" validate:"required"` + ID string `path:"id" validate:"required"` + Username string `path:"username" validate:"required"` +} + type CopyResearchMapData struct { AudienceSummary string `json:"audience_summary,omitempty"` ContentGoal string `json:"content_goal,omitempty"` @@ -286,17 +323,21 @@ type CopyResearchMapData struct { } type CopySimilarAccountData struct { - Username string `json:"username"` - Reason string `json:"reason,omitempty"` - Source string `json:"source,omitempty"` - Confidence string `json:"confidence,omitempty"` - ProfileUrl string `json:"profile_url,omitempty"` - AuthorVerified bool `json:"author_verified,omitempty"` - FollowerCount int `json:"follower_count,omitempty"` - EngagementScore int `json:"engagement_score,omitempty"` - LikeCount int `json:"like_count,omitempty"` - ReplyCount int `json:"reply_count,omitempty"` - PostCount int `json:"post_count,omitempty"` + Username string `json:"username"` + Reason string `json:"reason,omitempty"` + Source string `json:"source,omitempty"` + MatchedSource []string `json:"matched_source,omitempty"` + Confidence string `json:"confidence,omitempty"` + Status string `json:"status,omitempty"` + TopicRelevance float64 `json:"topic_relevance,omitempty"` + LastSeenAt int64 `json:"last_seen_at,omitempty"` + ProfileUrl string `json:"profile_url,omitempty"` + AuthorVerified bool `json:"author_verified,omitempty"` + FollowerCount int `json:"follower_count,omitempty"` + EngagementScore int `json:"engagement_score,omitempty"` + LikeCount int `json:"like_count,omitempty"` + ReplyCount int `json:"reply_count,omitempty"` + PostCount int `json:"post_count,omitempty"` } type CopySuggestedTagData struct { @@ -370,6 +411,25 @@ type CreateThreadsAccountReq struct { Activate *bool `json:"activate,optional"` } +type DeleteCopyDraftData struct { + DraftID string `json:"draft_id"` + Message string `json:"message"` +} + +type DeleteCopyMissionMatrixDraftsData struct { + Deleted int `json:"deleted"` + Message string `json:"message"` +} + +type DeleteCopyMissionMatrixDraftsHandlerReq struct { + CopyMissionPath + DeleteCopyMissionMatrixDraftsReq +} + +type DeleteCopyMissionMatrixDraftsReq struct { + DraftIDs []string `json:"draft_ids,optional"` +} + type DeletePlacementTopicScanPostHandlerReq struct { PlacementTopicPath PostID string `path:"postId" validate:"required"` @@ -382,6 +442,11 @@ type ErrorDetail struct { Detail int64 `json:"detail,optional"` } +type ExpandCopyMissionGraphHandlerReq struct { + CopyMissionPath + ExpandKnowledgeGraphReq +} + type ExpandKnowledgeGraphData struct { JobID string `json:"job_id"` Status string `json:"status"` @@ -771,6 +836,10 @@ type ListPlacementTopicsData struct { List []PlacementTopicData `json:"list"` } +type ListThreadsAccountAiProviderModelsReq struct { + ApiKey string `json:"api_key,optional"` // 選填;未帶則用已儲存的 key +} + type ListThreadsAccountsData struct { List []ThreadsAccountData `json:"list"` ActiveAccountID string `json:"active_account_id"` @@ -792,6 +861,16 @@ type MePermissionsQuery struct { IncludeTree bool `form:"include_tree,optional"` } +type MemberCapabilitiesData struct { + DiscoverReady bool `json:"discover_ready"` + AiReady bool `json:"ai_ready"` + PublishReady bool `json:"publish_ready"` + DiscoverHint string `json:"discover_hint,omitempty"` + AiHint string `json:"ai_hint,omitempty"` + PublishHint string `json:"publish_hint,omitempty"` + ActiveThreadsAccountId string `json:"active_threads_account_id,omitempty"` +} + type MemberMeData struct { TenantID string `json:"tenant_id"` UID string `json:"uid"` @@ -837,6 +916,20 @@ type PaginationData struct { TotalPages int64 `json:"totalPages"` } +type PatchAudienceSampleHandlerReq struct { + CopyMissionSimilarAccountPath + PatchAudienceSampleReq +} + +type PatchAudienceSampleReq struct { + Status string `json:"status"` +} + +type PatchCopyMissionGraphNodesHandlerReq struct { + CopyMissionPath + PatchKnowledgeGraphNodesReq +} + type PatchKnowledgeGraphNodesHandlerReq struct { BrandPath PatchKnowledgeGraphNodesReq @@ -867,6 +960,15 @@ type PatchScanPostOutreachReq struct { OutreachStatus *string `json:"outreach_status,optional"` } +type PatchSimilarAccountHandlerReq struct { + CopyMissionSimilarAccountPath + PatchSimilarAccountReq +} + +type PatchSimilarAccountReq struct { + Status string `json:"status"` +} + type PermissionCatalogData struct { Tree []PermissionNode `json:"tree,omitempty"` List []PermissionNode `json:"list,omitempty"` @@ -1175,6 +1277,24 @@ type StorePersonaStyleProfileReq struct { StyleBenchmark string `json:"style_benchmark,optional"` } +type ThreadsAccountAiProviderModelsData struct { + ID string `json:"id"` + Label string `json:"label"` + Models []string `json:"models"` + Streams bool `json:"streams"` + Error string `json:"error,optional"` +} + +type ThreadsAccountAiProviderModelsHandlerReq struct { + ThreadsAccountAiProviderPath + ListThreadsAccountAiProviderModelsReq +} + +type ThreadsAccountAiProviderPath struct { + ID string `path:"id" validate:"required"` + Provider string `path:"provider" validate:"required,oneof=opencode-go xai"` +} + type ThreadsAccountAiSettingsData struct { AccountID string `json:"account_id"` Provider string `json:"provider"` @@ -1185,28 +1305,6 @@ type ThreadsAccountAiSettingsData struct { ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"` } -type ThreadsAccountAiProviderPath struct { - ID string `path:"id" validate:"required"` - Provider string `path:"provider" validate:"required,oneof=opencode-go xai"` -} - -type ListThreadsAccountAiProviderModelsReq struct { - ApiKey string `json:"api_key,optional"` -} - -type ThreadsAccountAiProviderModelsHandlerReq struct { - ThreadsAccountAiProviderPath - ListThreadsAccountAiProviderModelsReq -} - -type ThreadsAccountAiProviderModelsData struct { - ID string `json:"id"` - Label string `json:"label"` - Models []string `json:"models"` - Streams bool `json:"streams"` - Error string `json:"error,optional"` -} - type ThreadsAccountConnectionData struct { AccountID string `json:"account_id"` AccountName string `json:"account_name"` @@ -1296,17 +1394,19 @@ type UpdateCopyMissionHandlerReq struct { } type UpdateCopyMissionReq struct { - Label *string `json:"label,optional"` - SeedQuery *string `json:"seed_query,optional"` - Brief *string `json:"brief,optional"` - AudienceSummary *string `json:"audience_summary,optional"` - ContentGoal *string `json:"content_goal,optional"` - Questions []string `json:"questions,optional"` - Pillars []string `json:"pillars,optional"` - Exclusions []string `json:"exclusions,optional"` - BenchmarkNotes *string `json:"benchmark_notes,optional"` - SelectedTags []string `json:"selected_tags,optional"` - Status *string `json:"status,optional"` + Label *string `json:"label,optional"` + SeedQuery *string `json:"seed_query,optional"` + Brief *string `json:"brief,optional"` + AudienceSummary *string `json:"audience_summary,optional"` + ContentGoal *string `json:"content_goal,optional"` + Questions []string `json:"questions,optional"` + Pillars []string `json:"pillars,optional"` + Exclusions []string `json:"exclusions,optional"` + KnowledgeItems []string `json:"knowledge_items,optional"` + SelectedKnowledgeItems []string `json:"selected_knowledge_items,optional"` + BenchmarkNotes *string `json:"benchmark_notes,optional"` + SelectedTags []string `json:"selected_tags,optional"` + Status *string `json:"status,optional"` } type UpdateJobScheduleReq struct { diff --git a/backend/internal/worker/job/analyze_copy_mission.go b/backend/internal/worker/job/analyze_copy_mission.go index 018f2fc..a264901 100644 --- a/backend/internal/worker/job/analyze_copy_mission.go +++ b/backend/internal/worker/job/analyze_copy_mission.go @@ -7,7 +7,9 @@ import ( 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" @@ -124,20 +126,18 @@ func runAnalyzeCopyMission(ctx context.Context, step StepContext, deps AnalyzeCo Questions: parsed.Questions, Pillars: parsed.Pillars, Exclusions: parsed.Exclusions, + KnowledgeItems: baseKnowledgeItems(parsed), BenchmarkNotes: parsed.BenchmarkNotes, + SimilarAccounts: similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts), } - 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.SimilarAccounts = nil 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) @@ -178,6 +178,188 @@ func runAnalyzeCopyMission(ctx context.Context, step StepContext, deps AnalyzeCo 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 diff --git a/backend/internal/worker/job/audience_samples.go b/backend/internal/worker/job/audience_samples.go new file mode 100644 index 0000000..41d2507 --- /dev/null +++ b/backend/internal/worker/job/audience_samples.go @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..3470b6e --- /dev/null +++ b/backend/internal/worker/job/expand_copy_mission_graph.go @@ -0,0 +1,365 @@ +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 + } + + graph, err := libkg.ParseSynthOutput(result.Text, libkg.SynthInput{ + Seed: seed, + TargetAudience: mission.ResearchMap.AudienceSummary, + }, braveSources) + if err != nil { + return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + err.Error()) + } + 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, libkg.SynthInput{ + Seed: seed, + TargetAudience: mission.ResearchMap.AudienceSummary, + }, 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 +} \ No newline at end of file diff --git a/backend/internal/worker/job/generate_copy_matrix.go b/backend/internal/worker/job/generate_copy_matrix.go index ea37f20..47ac1a5 100644 --- a/backend/internal/worker/job/generate_copy_matrix.go +++ b/backend/internal/worker/job/generate_copy_matrix.go @@ -3,10 +3,14 @@ package job import ( "context" "fmt" + "strings" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" + libcopy "haixun-backend/internal/library/copymission" + libkg "haixun-backend/internal/library/knowledge" libmatrix "haixun-backend/internal/library/matrix" + libweb "haixun-backend/internal/library/webpage" libprompt "haixun-backend/internal/library/prompt" "haixun-backend/internal/library/style8d" domai "haixun-backend/internal/model/ai/domain/usecase" @@ -15,6 +19,7 @@ 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" + kgusecase "haixun-backend/internal/model/knowledge_graph/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" @@ -27,6 +32,7 @@ type GenerateCopyMatrixDeps struct { Persona personadomain.UseCase ScanPost scanpostdomain.UseCase CopyDraft copydraftdomain.UseCase + KnowledgeGraph kgusecase.UseCase ThreadsAccount threadsaccountdomain.UseCase AI aiusecase.UseCase } @@ -53,12 +59,13 @@ func runGenerateCopyMatrix(ctx context.Context, step StepContext, deps GenerateC if err != nil { return err } - if mission.Status != string(missionentity.StatusScanned) && + if mission.Status != string(missionentity.StatusMapped) && + mission.Status != string(missionentity.StatusScanned) && mission.Status != string(missionentity.StatusDrafted) { - return app.For(code.Persona).ResInvalidState("請先完成海巡再產出內容矩陣") + return app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣") } - if len(mission.SelectedTags) == 0 { - return app.For(code.Persona).InputMissingRequired("請先選擇海巡標籤") + if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" { + return app.For(code.Persona).InputMissingRequired("請先完成研究地圖") } persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID) @@ -102,12 +109,34 @@ func runGenerateCopyMatrix(ctx context.Context, step StepContext, deps GenerateC return err } samples := matrixSamplesFromPosts(posts) - researchBlock := libmatrix.FormatCopyResearchMapBlock( + 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, @@ -126,7 +155,11 @@ func runGenerateCopyMatrix(ctx context.Context, step StepContext, deps GenerateC return app.For(code.AI).SysInternal("matrix system prompt load failed") } - updateProgress("產出內容矩陣草稿…", 45) + 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 { @@ -136,7 +169,7 @@ func runGenerateCopyMatrix(ctx context.Context, step StepContext, deps GenerateC if err != nil { return err } - result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ + parsed, err := libmatrix.GenerateCopyOutput(ctx, deps.AI, domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ @@ -146,11 +179,7 @@ func runGenerateCopyMatrix(ctx context.Context, step StepContext, deps GenerateC Messages: []domai.Message{ {Role: "user", Content: userPrompt}, }, - }) - if err != nil { - return err - } - parsed, err := libmatrix.ParseGenerateOutput(result.Text) + }, count) if err != nil { return app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error()) } @@ -206,6 +235,21 @@ func runGenerateCopyMatrix(ctx context.Context, step StepContext, deps GenerateC 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 { diff --git a/backend/internal/worker/job/runner.go b/backend/internal/worker/job/runner.go index e68d554..513b209 100644 --- a/backend/internal/worker/job/runner.go +++ b/backend/internal/worker/job/runner.go @@ -3,6 +3,7 @@ package job import ( "context" "errors" + "fmt" "log" "time" @@ -201,6 +202,9 @@ func (r *Runner) runStep(ctx context.Context, run *entity.Run, template *entity. }, }) } + if run.TemplateType != "demo_long_task" { + return fmt.Errorf("no handler registered for step %q (template %s)", step.ID, run.TemplateType) + } ticks := 10 sleepEach := 500 * time.Millisecond if step.ID == "execute" { diff --git a/backend/internal/worker/job/scan_viral.go b/backend/internal/worker/job/scan_viral.go index e55840d..e50fbcd 100644 --- a/backend/internal/worker/job/scan_viral.go +++ b/backend/internal/worker/job/scan_viral.go @@ -5,10 +5,12 @@ import ( "fmt" "strings" + "haixun-backend/internal/library/clock" 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" copydraftusecase "haixun-backend/internal/model/copy_draft/domain/usecase" @@ -19,6 +21,7 @@ import ( personadomain "haixun-backend/internal/model/persona/domain/usecase" placementusecase "haixun-backend/internal/model/placement/usecase" scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" + accountentity "haixun-backend/internal/model/threads_account/domain/entity" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" ) @@ -136,6 +139,9 @@ func runScanViral(ctx context.Context, step StepContext, deps ScanViralDeps) err candidates, err := libviral.RunDiscover(ctx, libviral.DiscoverInput{ Keywords: keywords, Exclusions: exclusions, + SeedQuery: missionSeedQuery(mission), + Label: missionLabel(mission), + TopicHints: missionTopicHints(mission), Member: memberCtx, Crawler: crawlerFn, MissionScan: missionScan, @@ -170,12 +176,28 @@ func runScanViral(ctx context.Context, step StepContext, deps ScanViralDeps) err if missionScan && missionID != "" && mission != nil { scanned := missionentity.StatusScanned jobID := step.JobID + prevSimilar := similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts) + var knownAccounts map[string]accountentity.KnownAccountProfile + if memberCtx.ActiveAccountID != "" && deps.ThreadsAccount != nil { + knownAccounts, _ = deps.ThreadsAccount.GetKnownAccounts(ctx, tenantID, ownerUID, memberCtx.ActiveAccountID) + } + excluded := libviral.ExcludedSimilarAccountUsernames(prevSimilar) + excluded = append(excluded, libviral.ExcludedKnownAccountUsernames(knownAccounts)...) referenceAccounts := libviral.BuildReferenceAccountsFromScan(libviral.ReferenceAccountInput{ - SeedQuery: mission.SeedQuery, - Label: mission.Label, - Posts: candidates, - Limit: libviral.MaxSimilarAccounts, + SeedQuery: mission.SeedQuery, + Label: mission.Label, + Posts: candidates, + Limit: libviral.MaxSimilarAccounts, + ExcludedUsernames: excluded, }) + // Web-search fallback: only when scan surfaced fewer than + // MinSimilarAccountsBeforeWebSupplement (5) reference authors — avoids + // extra search API calls when scan data is already sufficient. + // (site:threads.net "seed" + pillars). Failures are logged-quiet and must + // not fail the scan job — the scan-derived accounts are still valid. + if len(referenceAccounts) < libviral.MinSimilarAccountsBeforeWebSupplement && placement.WebSearchAvailable(research) { + referenceAccounts = supplementSimilarAccountsFromWeb(ctx, referenceAccounts, memberCtx, mission) + } entityTags := make([]missionentity.SuggestedTag, 0, len(mission.ResearchMap.SuggestedTags)) for _, tag := range mission.ResearchMap.SuggestedTags { entityTags = append(entityTags, missionentity.SuggestedTag{ @@ -185,6 +207,36 @@ func runScanViral(ctx context.Context, step StepContext, deps ScanViralDeps) err SearchType: tag.SearchType, }) } + mergedSimilar := libviral.MergeSimilarAccounts(prevSimilar, referenceAccounts) + mergedSimilar = libviral.ApplyKnownAccountMemory(mergedSimilar, knownAccounts) + if memberCtx.ActiveAccountID != "" && deps.ThreadsAccount != nil { + _ = deps.ThreadsAccount.UpsertKnownAccountsFromScan(ctx, threadsaccountdomain.UpsertKnownAccountsFromScanRequest{ + TenantID: tenantID, + OwnerUID: ownerUID, + AccountID: memberCtx.ActiveAccountID, + MissionID: missionID, + PersonaID: personaID, + SelectedTags: mission.SelectedTags, + SimilarAccounts: mergedSimilar, + }) + } + var audienceSamples []missionentity.AudienceSample + if memberCtx.ApiConnected && memberCtx.ScrapeReplies { + var allReplies []placement.ReplyCandidate + for _, post := range candidates { + allReplies = append(allReplies, post.Replies...) + } + if len(allReplies) > 0 { + audienceSamples = libviral.MergeAudienceSamples( + audienceSamplesFromSummary(mission.ResearchMap.AudienceSamples), + libviral.BuildAudienceSamplesFromReplies(allReplies, libviral.AudienceOpts{ + Max: libviral.MaxAudienceSamples, + ExcludeAuthors: libviral.SimilarAccountUsernames(mergedSimilar), + Now: clock.NowUnixNano(), + }), + ) + } + } updatedMap := missionentity.ResearchMap{ AudienceSummary: mission.ResearchMap.AudienceSummary, ContentGoal: mission.ResearchMap.ContentGoal, @@ -192,7 +244,8 @@ func runScanViral(ctx context.Context, step StepContext, deps ScanViralDeps) err Pillars: append([]string(nil), mission.ResearchMap.Pillars...), Exclusions: append([]string(nil), mission.ResearchMap.Exclusions...), SuggestedTags: entityTags, - SimilarAccounts: referenceAccounts, + SimilarAccounts: mergedSimilar, + AudienceSamples: audienceSamples, BenchmarkNotes: mission.ResearchMap.BenchmarkNotes, } _, _ = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{ @@ -336,6 +389,52 @@ func personaIDFromPayload(payload map[string]any) string { return stringField(payload, "scope_id") } +func missionSeedQuery(mission *missiondomain.MissionSummary) string { + if mission == nil { + return "" + } + return mission.SeedQuery +} + +func missionLabel(mission *missiondomain.MissionSummary) string { + if mission == nil { + return "" + } + return mission.Label +} + +func missionTopicHints(mission *missiondomain.MissionSummary) []string { + if mission == nil { + return nil + } + out := []string{} + seen := map[string]struct{}{} + add := func(item string) { + item = strings.TrimSpace(item) + if item == "" { + return + } + if _, ok := seen[item]; ok { + return + } + seen[item] = struct{}{} + out = append(out, item) + } + for _, tag := range mission.SelectedTags { + add(tag) + } + for _, tag := range mission.ResearchMap.SuggestedTags { + add(tag.Tag) + } + for _, pillar := range mission.ResearchMap.Pillars { + add(pillar) + } + for _, question := range mission.ResearchMap.Questions { + add(question) + } + return out +} + func deriveViralKeywords(persona *personadomain.PersonaSummary) []string { if persona == nil { return nil @@ -371,3 +470,47 @@ func deriveViralKeywords(persona *personadomain.PersonaSummary) []string { } return out } + +// supplementSimilarAccountsFromWeb asks the configured web search provider +// for threads.net profiles matching the mission seed/brief/pillars, then +// merges the results with the scan-derived accounts via EnrichSimilarAccounts. +// It must never return an error: web search outage is non-fatal. Confidence is +// upgraded to "high" for usernames that appear in both sources (scan+web). +func supplementSimilarAccountsFromWeb( + ctx context.Context, + scanAccounts []missionentity.SimilarAccount, + memberCtx placement.MemberContext, + mission *missiondomain.MissionSummary, +) []missionentity.SimilarAccount { + if memberCtx.WebSearchAPIKey() == "" { + return scanAccounts + } + client := websearch.New(memberCtx.WebSearchConfig()) + if !client.Enabled() { + return scanAccounts + } + webAccounts, err := libviral.DiscoverSimilarAccounts(ctx, client, libviral.DiscoverAccountsInput{ + SeedQuery: mission.SeedQuery, + Brief: mission.Brief, + Pillars: mission.ResearchMap.Pillars, + }) + if err != nil || len(webAccounts) == 0 { + return scanAccounts + } + entityWeb := make([]missionentity.SimilarAccount, 0, len(webAccounts)) + for _, w := range webAccounts { + matched := w.MatchedSource + if len(matched) == 0 { + matched = []string{"web"} + } + entityWeb = append(entityWeb, missionentity.SimilarAccount{ + Username: w.Username, + Reason: w.Reason, + Source: "web", + MatchedSource: matched, + Confidence: w.Confidence, + ProfileURL: w.ProfileURL, + }) + } + return libviral.MergeSimilarAccounts(scanAccounts, entityWeb) +} diff --git a/backend/internal/worker/job/similar_accounts.go b/backend/internal/worker/job/similar_accounts.go new file mode 100644 index 0000000..19f9b7a --- /dev/null +++ b/backend/internal/worker/job/similar_accounts.go @@ -0,0 +1,42 @@ +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/frontend/src/components/CopyFlowPipeline.tsx b/frontend/src/components/CopyFlowPipeline.tsx index 7117e91..ff819d0 100644 --- a/frontend/src/components/CopyFlowPipeline.tsx +++ b/frontend/src/components/CopyFlowPipeline.tsx @@ -8,19 +8,19 @@ export function CopyFlowPipeline() {

拷貝忍者

-

研究地圖 → 爆款海巡 → 矩陣草稿

+

研究地圖 → 延伸知識 → 矩陣草稿

在人設庫完成 8D 對標分析 - ,建立拷貝任務產生研究地圖、勾選標籤海巡,再產出可編輯的內容矩陣或單篇深度仿寫。 + ,建立拷貝任務產生研究地圖、補充相似帳號與延伸知識,再產出可編輯的內容矩陣。

拷貝忍者

-

拷貝任務、爆款海巡與草稿編輯

+

拷貝任務、延伸研究與草稿編輯

@@ -28,4 +28,4 @@ export function CopyFlowPipeline() {
) -} \ No newline at end of file +} diff --git a/frontend/src/components/CopyMissionAnalyzeJobPanel.tsx b/frontend/src/components/CopyMissionAnalyzeJobPanel.tsx index 34c6969..771b9a2 100644 --- a/frontend/src/components/CopyMissionAnalyzeJobPanel.tsx +++ b/frontend/src/components/CopyMissionAnalyzeJobPanel.tsx @@ -1,4 +1,5 @@ import { ANALYZE_COPY_MISSION_PIPELINE_STEPS } from '../lib/copyFlow' +import { jobActivitySummary } from '../lib/jobMonitorUi' import { jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus' import type { JobData } from '../types/api' import { ProgressBar, StatusBadge } from './ui' @@ -12,16 +13,6 @@ const STEP_STATUS_LABEL: Record = { cancelled: '取消', } -function analyzeJobHint(job: JobData): string { - if (job.status === 'cancel_requested') { - return '研究地圖任務取消中,通常幾秒內完成。' - } - if (job.status === 'queued' || job.status === 'pending') { - return '任務已排隊,背景 worker 即將接手…' - } - return job.progress?.summary?.trim() || '研究地圖產生中,請稍候…' -} - export function CopyMissionAnalyzeJobPanel({ job }: { job: JobData }) { const steps = job.progress?.steps ?? [] const stepMap = new Map(steps.map((s) => [s.id, s])) @@ -29,7 +20,7 @@ export function CopyMissionAnalyzeJobPanel({ job }: { job: JobData }) { return (
-

{analyzeJobHint(job)}

+

{jobActivitySummary(job)}

{jobStatusLabel(job.status)} diff --git a/frontend/src/components/CopyMissionDraftCard.tsx b/frontend/src/components/CopyMissionDraftCard.tsx index 8710a9c..eec562a 100644 --- a/frontend/src/components/CopyMissionDraftCard.tsx +++ b/frontend/src/components/CopyMissionDraftCard.tsx @@ -17,8 +17,14 @@ type Props = { onMarkReady: () => void onCopy: () => void | Promise onPublish: () => void + onDelete?: () => void + publishReady?: boolean saving?: boolean publishing?: boolean + deleting?: boolean + batchMode?: boolean + batchSelected?: boolean + onBatchSelect?: (selected: boolean) => void } function draftKindLabel(type?: string) { @@ -46,8 +52,14 @@ export function CopyMissionDraftCard({ onMarkReady, onCopy, onPublish, + onDelete, + publishReady = true, saving, publishing, + deleting, + batchMode, + batchSelected, + onBatchSelect, }: Props) { const hook = editing ? edit.hook : draft.hook const text = editing ? edit.text : draft.text @@ -71,6 +83,15 @@ export function CopyMissionDraftCard({
+ {batchMode ? ( + onBatchSelect?.(e.target.checked)} + aria-label={`選取草稿 ${draft.sort_order ?? draft.id}`} + /> + ) : null} {draftKindLabel(draft.draft_type)} {draft.sort_order ? #{draft.sort_order} : null} {draft.angle ? {draft.angle} : null} @@ -84,7 +105,7 @@ export function CopyMissionDraftCard({ - {draft.status !== 'published' ? ( + {draft.status !== 'published' && publishReady ? ( + ) : null}
) : null}
diff --git a/frontend/src/components/CopyMissionJobPanel.tsx b/frontend/src/components/CopyMissionJobPanel.tsx index cff8bb2..e2068b4 100644 --- a/frontend/src/components/CopyMissionJobPanel.tsx +++ b/frontend/src/components/CopyMissionJobPanel.tsx @@ -1,3 +1,4 @@ +import { jobActivitySummary } from '../lib/jobMonitorUi' import { jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus' import type { JobData } from '../types/api' import { ProgressBar, StatusBadge } from './ui' @@ -23,7 +24,7 @@ export function CopyMissionJobPanel({ job, steps, fallbackSummary }: Props) { return (
-

{job.progress?.summary || fallbackSummary}

+

{jobActivitySummary(job) || fallbackSummary}

{jobStatusLabel(job.status)} diff --git a/frontend/src/components/CopyMissionKnowledgeTab.tsx b/frontend/src/components/CopyMissionKnowledgeTab.tsx new file mode 100644 index 0000000..23ea572 --- /dev/null +++ b/frontend/src/components/CopyMissionKnowledgeTab.tsx @@ -0,0 +1,84 @@ +import { useMemo } from 'react' +import type { KnowledgeGraphData, KnowledgeGraphNode } from '../lib/knowledgeGraph' +import { mergeCopyMissionSources } from '../lib/copyMissionGraph' +import type { CopyMissionData } from '../types/copyMission' +import { KnowledgeOverviewPanel } from './KnowledgeOverviewPanel' +import { Button, Notice } from './ui' + +type Props = { + mission: CopyMissionData + graph: KnowledgeGraphData | null + graphLoading?: boolean + draftNodes: KnowledgeGraphNode[] + saving?: boolean + expanding?: boolean + discoverReady?: boolean + onToggle: (nodeId: string, selected: boolean) => void + onSelectPainNodes: () => void + onClearSelection: () => void + onSaveSelection: () => void + onExpandKnowledge: () => void +} + +export function CopyMissionKnowledgeTab({ + mission, + graph, + graphLoading, + draftNodes, + saving, + expanding, + discoverReady, + onToggle, + onSelectPainNodes, + onClearSelection, + onSaveSelection, + onExpandKnowledge, +}: Props) { + const sources = useMemo( + () => mergeCopyMissionSources(mission.research_map, graph), + [mission.research_map, graph], + ) + const hasGraph = (graph?.nodes?.length ?? 0) > 0 + + if (graphLoading && !hasGraph) { + return

載入延伸知識…

+ } + + if (!hasGraph) { + return ( +
+ + {discoverReady === false ? ( + + ) : null} + +
+ ) + } + + return ( + + ) +} \ No newline at end of file diff --git a/frontend/src/components/CopyMissionResearchEditor.tsx b/frontend/src/components/CopyMissionResearchEditor.tsx index 2c25b0c..1accf09 100644 --- a/frontend/src/components/CopyMissionResearchEditor.tsx +++ b/frontend/src/components/CopyMissionResearchEditor.tsx @@ -11,6 +11,8 @@ export type CopyMissionResearchMapDraft = { questions: string[] pillars: string[] exclusions: string[] + knowledge_items: string[] + selected_knowledge_items: string[] benchmark_notes: string selected_tags: string[] } @@ -44,6 +46,8 @@ function toDraft(mission: CopyMissionData): CopyMissionResearchMapDraft { questions: [...(map?.questions ?? [])], pillars: [...(map?.pillars ?? [])], exclusions: [...(map?.exclusions ?? [])], + knowledge_items: [...(map?.knowledge_items ?? [])], + selected_knowledge_items: [...(map?.selected_knowledge_items ?? [])], benchmark_notes: map?.benchmark_notes?.trim() ?? '', selected_tags: selected.length > 0 ? selected : [], } @@ -55,11 +59,12 @@ export function CopyMissionResearchEditor({ mission, onSave, onCancel }: Props) const [saving, setSaving] = useState(false) const [error, setError] = useState('') - useEffect(() => { - setDraft(saved) - }, [saved]) - const dirty = JSON.stringify(draft) !== JSON.stringify(saved) + + useEffect(() => { + if (dirty) return + setDraft(saved) + }, [dirty, saved]) const suggestions = useMemo(() => { const current = new Set(draft.selected_tags.map((item) => item.trim()).filter(Boolean)) return (mission.research_map?.suggested_tags ?? []) @@ -112,6 +117,8 @@ export function CopyMissionResearchEditor({ mission, onSave, onCancel }: Props) questions: cleanLines(draft.questions), pillars: cleanLines(draft.pillars), exclusions: cleanLines(draft.exclusions), + knowledge_items: cleanLines(draft.knowledge_items), + selected_knowledge_items: cleanLines(draft.selected_knowledge_items), benchmark_notes: draft.benchmark_notes.trim(), selected_tags: cleanLines(draft.selected_tags).slice(0, MAX_COPY_SCAN_TAGS), } @@ -182,6 +189,85 @@ export function CopyMissionResearchEditor({ mission, onSave, onCancel }: Props) />
+
+

延伸知識

+

+ 左側勾選要餵給矩陣產文的項目;沒有圖譜時可手動補充知識重點。 +

+ {draft.knowledge_items.length > 0 ? ( +
    + {draft.knowledge_items.map((item, index) => { + const trimmed = item.trim() + const checked = trimmed ? draft.selected_knowledge_items.includes(trimmed) : false + return ( +
  1. + + setDraft((prev) => ({ + ...prev, + selected_knowledge_items: e.target.checked + ? cleanLines([...prev.selected_knowledge_items, trimmed]) + : prev.selected_knowledge_items.filter((cur) => cur !== trimmed), + })) + } + aria-label={trimmed ? `勾選 ${trimmed}` : '請先輸入知識內容'} + /> + {index + 1} + { + const next = e.target.value + setDraft((prev) => { + const prevTrimmed = item.trim() + const nextSelected = prev.selected_knowledge_items.filter((cur) => cur !== prevTrimmed) + if (next.trim() && prev.selected_knowledge_items.includes(prevTrimmed)) { + nextSelected.push(next.trim()) + } + return { + ...prev, + knowledge_items: prev.knowledge_items.map((row, i) => (i === index ? next : row)), + selected_knowledge_items: cleanLines(nextSelected), + } + }) + }} + placeholder="例:男性備孕可關注鋅、葉酸、抗氧化營養素與作息" + className="flex-1 text-[15px]" + /> + +
  2. + ) + })} +
+ ) : ( +

尚無項目,可按下方新增。

+ )} +
+ +
+
+