This commit is contained in:
王性驊 2026-06-28 16:28:42 +08:00
parent ae89e0451e
commit acd6eb93e9
160 changed files with 8838 additions and 688 deletions

View File

@ -214,6 +214,10 @@ install: ## [prod] 安裝 binary/前端/設定/systemd/nginx需 root在目
tidy: ## go mod tidy tidy: ## go mod tidy
cd $(BACKEND_DIR) && 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 .PHONY: fmt
fmt: ## gofmt 後端 fmt: ## gofmt 後端
cd $(BACKEND_DIR) && gofmt -w . cd $(BACKEND_DIR) && gofmt -w .

View File

@ -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/@<u>``.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 加「來源」badgescan+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 schemaPhase 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` 新排序 casetopicHits 加權、log-scale follower bucket、`worker/job/scan_viral_test.go` web fallback casemock 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 bucket1k/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` 串接 priorityscan+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。PlacementB 流海巡獲客)刻意維持「無對標帳號」設計,本計畫不動其 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/RECENT50/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/@<username>` 拼字串(`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/@<u>/`
- fallback `https://www.threads.com/@<username>`(注意 `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 — 相似帳號變一等公民(動 APICopyMission 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.<username>.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 時直接跳過整段,不報錯。

View File

@ -9,17 +9,39 @@ type (
} }
CopySimilarAccountData { CopySimilarAccountData {
Username string `json:"username"` Username string `json:"username"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
Confidence string `json:"confidence,omitempty"` MatchedSource []string `json:"matched_source,omitempty"`
ProfileUrl string `json:"profile_url,omitempty"` Confidence string `json:"confidence,omitempty"`
AuthorVerified bool `json:"author_verified,omitempty"` Status string `json:"status,omitempty"`
FollowerCount int `json:"follower_count,omitempty"` TopicRelevance float64 `json:"topic_relevance,omitempty"`
EngagementScore int `json:"engagement_score,omitempty"` LastSeenAt int64 `json:"last_seen_at,omitempty"`
LikeCount int `json:"like_count,omitempty"` ProfileUrl string `json:"profile_url,omitempty"`
ReplyCount int `json:"reply_count,omitempty"` AuthorVerified bool `json:"author_verified,omitempty"`
PostCount int `json:"post_count,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 { CopyMissionResearchMapData {
@ -28,11 +50,49 @@ type (
Questions []string `json:"questions,omitempty"` Questions []string `json:"questions,omitempty"`
Pillars []string `json:"pillars,omitempty"` Pillars []string `json:"pillars,omitempty"`
Exclusions []string `json:"exclusions,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"` SuggestedTags []CopySuggestedTagData `json:"suggested_tags,omitempty"`
SimilarAccounts []CopySimilarAccountData `json:"similar_accounts,omitempty"` SimilarAccounts []CopySimilarAccountData `json:"similar_accounts,omitempty"`
AudienceSamples []CopyAudienceSampleData `json:"audience_samples,omitempty"`
BenchmarkNotes string `json:"benchmark_notes,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 { CopyMissionData {
ID string `json:"id"` ID string `json:"id"`
PersonaID string `json:"persona_id"` PersonaID string `json:"persona_id"`
@ -66,6 +126,8 @@ type (
Questions []string `json:"questions,optional"` Questions []string `json:"questions,optional"`
Pillars []string `json:"pillars,optional"` Pillars []string `json:"pillars,optional"`
Exclusions []string `json:"exclusions,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"` BenchmarkNotes *string `json:"benchmark_notes,optional"`
SelectedTags []string `json:"selected_tags,optional"` SelectedTags []string `json:"selected_tags,optional"`
Status *string `json:"status,optional"` Status *string `json:"status,optional"`
@ -102,6 +164,15 @@ type (
PersonaID string `path:"personaId" validate:"required"` PersonaID string `path:"personaId" validate:"required"`
} }
CopyMissionInspirationReq {
Keyword string `json:"keyword,optional"`
}
CopyMissionInspirationHandlerReq {
PersonaCopyMissionsPath
CopyMissionInspirationReq
}
CreateCopyMissionHandlerReq { CreateCopyMissionHandlerReq {
PersonaCopyMissionsPath PersonaCopyMissionsPath
CreateCopyMissionReq CreateCopyMissionReq
@ -182,6 +253,20 @@ type (
Total int `json:"total"` Total int `json:"total"`
} }
DeleteCopyMissionMatrixDraftsReq {
DraftIDs []string `json:"draft_ids,optional"`
}
DeleteCopyMissionMatrixDraftsHandlerReq {
CopyMissionPath
DeleteCopyMissionMatrixDraftsReq
}
DeleteCopyMissionMatrixDraftsData {
Deleted int `json:"deleted"`
Message string `json:"message"`
}
CopyMissionInspirationSourceData { CopyMissionInspirationSourceData {
Query string `json:"query,omitempty"` Query string `json:"query,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
@ -213,7 +298,7 @@ service gateway {
get /:personaId/copy-missions (PersonaCopyMissionsPath) returns (ListCopyMissionsData) get /:personaId/copy-missions (PersonaCopyMissionsPath) returns (ListCopyMissionsData)
@handler inspireCopyMission @handler inspireCopyMission
post /:personaId/copy-mission-inspiration (PersonaCopyMissionsPath) returns (CopyMissionInspirationData) post /:personaId/copy-mission-inspiration (CopyMissionInspirationHandlerReq) returns (CopyMissionInspirationData)
@handler createCopyMission @handler createCopyMission
post /:personaId/copy-missions (CreateCopyMissionHandlerReq) returns (CopyMissionData) post /:personaId/copy-missions (CreateCopyMissionHandlerReq) returns (CopyMissionData)
@ -248,9 +333,27 @@ service gateway {
@handler listCopyMissionCopyDrafts @handler listCopyMissionCopyDrafts
get /:personaId/copy-missions/:id/copy-drafts (CopyMissionPath) returns (ListCopyMissionCopyDraftsData) 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 @handler getCopyMissionScanSchedule
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData) get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)
@handler upsertCopyMissionScanSchedule @handler upsertCopyMissionScanSchedule
put /:personaId/copy-missions/:id/scan-schedule (UpsertCopyMissionScanScheduleHandlerReq) returns (CopyMissionScanScheduleData) put /:personaId/copy-missions/:id/scan-schedule (UpsertCopyMissionScanScheduleHandlerReq) returns (CopyMissionScanScheduleData)
}
@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)
}

View File

@ -50,6 +50,16 @@ type (
ExaUserLocation *string `json:"exa_user_location,optional"` ExaUserLocation *string `json:"exa_user_location,optional"`
ExpandStrategy *string `json:"expand_strategy,optional"` ExpandStrategy *string `json:"expand_strategy,optional"`
} }
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( @server(
@ -71,4 +81,7 @@ service gateway {
@handler updateMemberPlacementSettings @handler updateMemberPlacementSettings
patch /me/placement-settings (UpdateMemberPlacementSettingsReq) returns (MemberPlacementSettingsData) patch /me/placement-settings (UpdateMemberPlacementSettingsReq) returns (MemberPlacementSettingsData)
@handler getMemberCapabilities
get /me/capabilities returns (MemberCapabilitiesData)
} }

View File

@ -183,6 +183,11 @@ type (
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
} }
DeleteCopyDraftData {
DraftID string `json:"draft_id"`
Message string `json:"message"`
}
) )
@server( @server(
@ -228,4 +233,7 @@ service gateway {
@handler publishPersonaCopyDraft @handler publishPersonaCopyDraft
post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData) post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData)
@handler deletePersonaCopyDraft
delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)
} }

View File

@ -1,6 +1,6 @@
module haixun-backend module haixun-backend
go 1.22 go 1.23
require ( require (
github.com/go-playground/validator/v10 v10.27.0 github.com/go-playground/validator/v10 v10.27.0
@ -14,6 +14,8 @@ require (
) )
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/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.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-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.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/golang/snappy v1.0.0 // indirect
github.com/grafana/pyroscope-go v1.2.7 // indirect github.com/grafana/pyroscope-go v1.2.7 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect

View File

@ -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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 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/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 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 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 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= 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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 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/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 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= 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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 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 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 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= 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.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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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.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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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= 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-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.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 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 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.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-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-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.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 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 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-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.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 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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= 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-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-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.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.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 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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-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.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.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.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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 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-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.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.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= 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 h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0= google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -13,7 +13,7 @@ import (
func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaCopyMissionsPath var req types.CopyMissionInspirationHandlerReq
if err := httpx.Parse(r, &req); err != nil { if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err)) response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return return

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -261,6 +261,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:personaId/copy-missions/:id/analyze-jobs", Path: "/:personaId/copy-missions/:id/analyze-jobs",
Handler: copy_mission.StartCopyMissionAnalyzeJobHandler(serverCtx), Handler: copy_mission.StartCopyMissionAnalyzeJobHandler(serverCtx),
}, },
{
Method: http.MethodPatch,
Path: "/:personaId/copy-missions/:id/audience-samples/:username",
Handler: copy_mission.PatchCopyMissionAudienceSampleHandler(serverCtx),
},
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/:personaId/copy-missions/:id/copy-draft-jobs", 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", Path: "/:personaId/copy-missions/:id/copy-drafts",
Handler: copy_mission.ListCopyMissionCopyDraftsHandler(serverCtx), 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, Method: http.MethodPost,
Path: "/:personaId/copy-missions/:id/matrix-drafts", Path: "/:personaId/copy-missions/:id/matrix-drafts",
Handler: copy_mission.GenerateCopyMissionMatrixHandler(serverCtx), Handler: copy_mission.GenerateCopyMissionMatrixHandler(serverCtx),
}, },
{
Method: http.MethodPost,
Path: "/:personaId/copy-missions/:id/matrix-drafts/delete",
Handler: copy_mission.DeleteCopyMissionMatrixDraftsHandler(serverCtx),
},
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/:personaId/copy-missions/:id/matrix-jobs", 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", Path: "/:personaId/copy-missions/:id/scan-schedule",
Handler: copy_mission.UpsertCopyMissionScanScheduleHandler(serverCtx), 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"), rest.WithPrefix("/api/v1/personas"),
@ -463,6 +493,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/me", Path: "/me",
Handler: member.UpdateMemberMeHandler(serverCtx), Handler: member.UpdateMemberMeHandler(serverCtx),
}, },
{
Method: http.MethodGet,
Path: "/me/capabilities",
Handler: member.GetMemberCapabilitiesHandler(serverCtx),
},
{ {
Method: http.MethodGet, Method: http.MethodGet,
Path: "/me/placement-settings", Path: "/me/placement-settings",
@ -547,6 +582,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/copy-drafts/:draftId", Path: "/:id/copy-drafts/:draftId",
Handler: persona.UpdatePersonaCopyDraftHandler(serverCtx), Handler: persona.UpdatePersonaCopyDraftHandler(serverCtx),
}, },
{
Method: http.MethodDelete,
Path: "/:id/copy-drafts/:draftId",
Handler: persona.DeletePersonaCopyDraftHandler(serverCtx),
},
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/:id/copy-drafts/:draftId/publish", Path: "/:id/copy-drafts/:draftId/publish",

View File

@ -27,4 +27,4 @@ func ListThreadsAccountAiProviderModelsHandler(svcCtx *svc.ServiceContext) http.
data, err := l.ListThreadsAccountAiProviderModels(&req) data, err := l.ListThreadsAccountAiProviderModels(&req)
response.Write(r.Context(), w, data, err) response.Write(r.Context(), w, data, err)
} }
} }

View File

@ -58,6 +58,12 @@ type SearchOptions struct {
Mode Mode Mode Mode
Country string Country string
SearchLang 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) { func (c *Client) Search(ctx context.Context, opts SearchOptions) (SearchResponse, error) {

View File

@ -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),
}
}

View File

@ -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")
}
}

View File

@ -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()
}

View File

@ -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, "/"))
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"haixun-backend/internal/library/websearch" "haixun-backend/internal/library/websearch"
) )
@ -22,6 +21,8 @@ type BraveCollectConfig struct {
Concurrency int Concurrency int
} }
const minUsefulSourceRunes = 24
func BraveCollectConfigFromQueryCfg(cfg queryConfig) BraveCollectConfig { func BraveCollectConfigFromQueryCfg(cfg queryConfig) BraveCollectConfig {
out := BraveCollectConfig{ out := BraveCollectConfig{
ResultsPerQuery: cfg.ResultsPerQuery, ResultsPerQuery: cfg.ResultsPerQuery,
@ -99,7 +100,7 @@ func collectWebSourcesSequential(
seenURL := map[string]struct{}{} seenURL := map[string]struct{}{}
for i, query := range queries { for i, query := range queries {
if shouldStopCollect(out, cfg) { if shouldStopCollect(out, queries, cfg) {
break break
} }
if heartbeat != nil { if heartbeat != nil {
@ -107,7 +108,7 @@ func collectWebSourcesSequential(
return out 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 { if onProgress != nil {
onProgress(i, len(queries)) onProgress(i, len(queries))
} }
@ -116,39 +117,44 @@ func collectWebSourcesSequential(
} }
type braveCollectState struct { type braveCollectState struct {
cfg BraveCollectConfig cfg BraveCollectConfig
mu sync.Mutex mu sync.Mutex
out []BraveSource out []BraveSource
seenURL map[string]struct{} seenURL map[string]struct{}
stop bool stop bool
completed int32
} }
func (s *braveCollectState) shouldStop(cfg BraveCollectConfig) bool { func (s *braveCollectState) shouldStop(queries []string, cfg BraveCollectConfig) bool {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.stop { if s.stop {
return true return true
} }
if shouldStopCollect(s.out, cfg) { if shouldStopCollect(s.out, queries, cfg) {
s.stop = true s.stop = true
return true return true
} }
return false return false
} }
func (s *braveCollectState) appendResults(query string, items []BraveSource) { func (s *braveCollectState) appendResults(query string, items []BraveSource, queries []string) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.stop { if s.stop {
return return
} }
appendBraveResults(&s.out, s.seenURL, query, items) appendBraveResults(&s.out, s.seenURL, query, items, s.cfg.MaxSourcesCap)
if shouldStopCollect(s.out, s.cfg) { if shouldStopCollect(s.out, queries, s.cfg) {
s.stop = true s.stop = true
} }
} }
func (s *braveCollectState) snapshot() []BraveSource {
s.mu.Lock()
defer s.mu.Unlock()
return append([]BraveSource(nil), s.out...)
}
func collectWebSourcesParallel( func collectWebSourcesParallel(
ctx context.Context, ctx context.Context,
client websearch.Client, client websearch.Client,
@ -172,45 +178,122 @@ func collectWebSourcesParallel(
workers = 1 workers = 1
} }
jobs := make(chan int, len(queries)) completed := 0
for i := range queries { for next := 0; next < len(queries); {
jobs <- i if state.shouldStop(queries, cfg) {
} break
close(jobs) }
batchEnd := next + workers
if batchEnd > len(queries) {
batchEnd = len(queries)
}
var wg sync.WaitGroup type queryResult struct {
for w := 0; w < workers; w++ { index int
wg.Add(1) items []BraveSource
go func() { }
defer wg.Done() results := make(chan queryResult, batchEnd-next)
for i := range jobs { var wg sync.WaitGroup
if state.shouldStop(cfg) { for i := next; i < batchEnd; i++ {
return if heartbeat != nil {
} if err := heartbeat(); err != nil {
if heartbeat != nil { return state.snapshot()
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))
} }
} }
}() 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.snapshot()
return state.out
} }
func shouldStopCollect(out []BraveSource, cfg BraveCollectConfig) bool { func shouldStopCollect(out []BraveSource, queries []string, cfg BraveCollectConfig) bool {
if len(out) >= cfg.MaxSourcesCap { if len(out) >= cfg.MaxSourcesCap {
return true 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( func searchWebQuery(
@ -244,8 +327,11 @@ func searchWebQuery(
return items 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 { for _, item := range items {
if max > 0 && len(*out) >= max {
return
}
url := strings.TrimSpace(item.URL) url := strings.TrimSpace(item.URL)
if url == "" { if url == "" {
continue continue

View File

@ -1,6 +1,13 @@
package knowledge package knowledge
import "testing" import (
"context"
"fmt"
"sync"
"testing"
"haixun-backend/internal/library/websearch"
)
func TestUniqueSourceCount(t *testing.T) { func TestUniqueSourceCount(t *testing.T) {
count := uniqueSourceCount([]BraveSource{ 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) { func TestPlanQueriesHybridBudget(t *testing.T) {
queries := PlanQueries(PlanInput{ queries := PlanQueries(PlanInput{
Seed: "敏感肌", Seed: "敏感肌",
@ -102,3 +164,34 @@ func TestSupplementalQueriesSkippedForHybrid(t *testing.T) {
t.Fatalf("hybrid supplemental brave should be empty, got %v", queries) 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)
}

View File

@ -54,7 +54,42 @@ func formatTagList(tags []string) string {
return strings.Join(lines, "\n") 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 var b strings.Builder
if audience = strings.TrimSpace(audience); audience != "" { if audience = strings.TrimSpace(audience); audience != "" {
b.WriteString("受眾:") b.WriteString("受眾:")
@ -76,6 +111,11 @@ func FormatCopyResearchMapBlock(audience, goal string, questions, pillars, exclu
b.WriteString(strings.Join(questions, "")) b.WriteString(strings.Join(questions, ""))
b.WriteString("\n") 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 { if len(exclusions) > 0 {
b.WriteString("排除:") b.WriteString("排除:")
b.WriteString(strings.Join(exclusions, "")) b.WriteString(strings.Join(exclusions, ""))

View File

@ -1,6 +1,7 @@
package matrix package matrix
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp" "regexp"
@ -8,6 +9,7 @@ import (
libprompt "haixun-backend/internal/library/prompt" libprompt "haixun-backend/internal/library/prompt"
"haixun-backend/internal/library/threadspost" "haixun-backend/internal/library/threadspost"
domai "haixun-backend/internal/model/ai/domain/usecase"
) )
type Row struct { type Row struct {
@ -42,7 +44,7 @@ type GenerateInput struct {
Count int Count int
} }
var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$") var codeFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
func BuildUserPrompt(in GenerateInput) (string, error) { func BuildUserPrompt(in GenerateInput) (string, error) {
count := in.Count count := in.Count
@ -95,15 +97,111 @@ func buildMaterialsBlock(posts []MaterialPost) string {
return strings.Join(lines, "\n\n") 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 建議 80220 字。",
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) { func ParseGenerateOutput(raw string) (GenerateResult, error) {
payload, err := extractJSONObject(raw) payload, err := extractJSONObject(raw)
if err != nil { if err != nil {
return GenerateResult{}, err return GenerateResult{}, err
} }
var out GenerateResult out, err := decodeGenerateResult(payload)
if err := json.Unmarshal(payload, &out); err != nil { 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 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 { if len(out.Rows) == 0 {
return GenerateResult{}, fmt.Errorf("matrix rows missing") return GenerateResult{}, fmt.Errorf("matrix rows missing")
} }
@ -134,9 +232,58 @@ func extractJSONObject(raw string) ([]byte, error) {
raw = strings.TrimSpace(m[1]) raw = strings.TrimSpace(m[1])
} }
start := strings.Index(raw, "{") start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}") if start < 0 {
if start < 0 || end <= start {
return nil, fmt.Errorf("matrix output missing json object") 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
} }

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -20,7 +20,7 @@ type ViralPostSample struct {
func FormatViralSamples(posts []ViralPostSample) string { func FormatViralSamples(posts []ViralPostSample) string {
if len(posts) == 0 { if len(posts) == 0 {
return "(尚無海巡樣本,請依研究地圖與標籤發揮" return "(尚無海巡樣本;請依研究地圖、延伸知識與人設產出,不要編造參考貼文"
} }
var b strings.Builder var b strings.Builder
limit := 8 limit := 8

View File

@ -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 連線後再發布貼文"
}

View File

@ -33,20 +33,20 @@ func (m MemberContext) CrawlerBlocked() bool {
// CrawlerFallbackAllowed returns true when crawler may be used after API/web search fails. // CrawlerFallbackAllowed returns true when crawler may be used after API/web search fails.
func (m MemberContext) CrawlerFallbackAllowed() bool { func (m MemberContext) CrawlerFallbackAllowed() bool {
if !m.AllowsCrawler || !m.BrowserConnected { if !m.BrowserConnected {
return false return false
} }
switch m.SearchSourceMode { switch m.SearchSourceMode {
case SearchSourceThreadsCrawler, SearchSourceBraveCrawler, SearchSourceMixed: case SearchSourceThreadsCrawler, SearchSourceBraveCrawler, SearchSourceMixed, SearchSourceThreads, SearchSourceBrave, SearchSourceThreadsBrave:
return true return true
default: default:
return false return m.AllowsCrawler
} }
} }
// HasDiscoverPath reports whether at least one discover backend is configured and connected. // HasDiscoverPath reports whether at least one discover backend is configured and connected.
func (m MemberContext) HasDiscoverPath() bool { func (m MemberContext) HasDiscoverPath() bool {
if m.AllowsCrawler && m.BrowserConnected { if m.BrowserConnected {
return true return true
} }
if m.AllowsThreadsAPI && m.ApiConnected { if m.AllowsThreadsAPI && m.ApiConnected {
@ -79,6 +79,9 @@ func (m MemberContext) DiscoverPathLabel() string {
} }
func discoverMissingPathError(m MemberContext) error { func discoverMissingPathError(m MemberContext) error {
if m.BrowserConnected {
return fmt.Errorf("Chrome Session 已同步,可使用爬蟲海巡;請重新整理後再試")
}
switch m.SearchSourceMode { switch m.SearchSourceMode {
case SearchSourceCrawler: case SearchSourceCrawler:
return fmt.Errorf("請先同步 Chrome Session 以使用爬蟲搜尋") return fmt.Errorf("請先同步 Chrome Session 以使用爬蟲搜尋")

View File

@ -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) { func TestBuildMemberContextFormalModeKeepsCrawlerMode(t *testing.T) {
prefs := ConnectionPrefsInput{ prefs := ConnectionPrefsInput{
DevMode: false, DevMode: false,

View File

@ -1,10 +1,11 @@
你是 Threads 內容矩陣策劃師,為人設帳號產出多篇可發佈草稿。 你是 Threads 內容矩陣策劃師,為人設帳號產出多篇可發佈草稿。
規則: 規則:
- 只回傳 JSON格式為 {"rows":[...]}。 - 只回傳單一 JSON 物件 {"rows":[...]},不要用 markdown 程式碼區塊,不要加前言或結語。
- reference_notes、rationale 各 ≤ 40 字;優先把 token 用在 text 主文。
- 每篇必須角度不同,避免重複 hook。 - 每篇必須角度不同,避免重複 hook。
- 套用人設語氣與 8D不要寫成品牌廣告或硬銷。 - 人設 8D 是最高優先:語氣、人稱、禁忌必須完全符合;不要寫成品牌廣告或硬銷。
- 參考爆款樣本只學結構與節奏,不抄原文。 - 內容論點來自研究地圖與使用者勾選的延伸知識(含標籤與細節);爆款樣本只學結構與節奏,不抄原文。
- 繁體中文,口語自然,適合 Threads。 - 繁體中文,口語自然,適合 Threads。
- 每篇 text 主文 ≤ 500 字Threads API 硬上限,含 #話題標籤)。 - 每篇 text 主文 ≤ 500 字Threads API 硬上限,含 #話題標籤)。
- 爆款互動最佳 80220 字:前 12 行強 hook一句一重點超過 300 字互動通常下降。 - 爆款互動最佳 80220 字:前 12 行強 hook一句一重點超過 300 字互動通常下降。

View File

@ -3,16 +3,25 @@
任務主題:{{topic_label}} 任務主題:{{topic_label}}
Brief{{topic_brief}} Brief{{topic_brief}}
研究地圖: 人設 8D最高優先語氣與禁忌以此為準
{{research_map_block}}
已選海巡標籤:
{{selected_tags_block}}
爆款樣本(只學結構):
{{viral_samples_block}}
人設 8D
{{persona_block}} {{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。 回傳 JSON rows每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。

View File

@ -37,19 +37,11 @@ func ParseStoredProfile(raw string) (*StoredProfile, bool) {
return &profile, true return &profile, true
} }
// HasReady8D returns true when 8D analysis exists and can drive copy generation. // HasReady8D returns true when D1D8 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 { func HasReady8D(personaText, styleProfileJSON string) bool {
if profile, ok := ParseStoredProfile(styleProfileJSON); ok { _ = personaText
if strings.TrimSpace(profile.PersonaDraft) != "" { return BuildStyle8DPromptBlock(styleProfileJSON) != ""
return true
}
for _, key := range dimensionOrder {
if summary := strings.TrimSpace(profile.Analysis[key].Summary); summary != "" {
return true
}
}
}
return strings.TrimSpace(personaText) != "" && strings.TrimSpace(styleProfileJSON) != ""
} }
// BuildStyle8DPromptBlock formats D1D8 summaries for LLM prompts. // BuildStyle8DPromptBlock formats D1D8 summaries for LLM prompts.

View File

@ -24,3 +24,10 @@ func TestHasReady8DFromAnalysisOnly(t *testing.T) {
t.Fatal("expected ready from analysis summary") 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")
}
}

View File

@ -0,0 +1,30 @@
package threadsapi
import (
"regexp"
"strings"
)
// profileAuthorRE captures the @<username> 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/@<username> when the
// permalink does not contain an @<username> 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
}

View File

@ -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
}

View File

@ -20,6 +20,9 @@ const (
type DiscoverInput struct { type DiscoverInput struct {
Keywords []string Keywords []string
Exclusions []string Exclusions []string
SeedQuery string
Label string
TopicHints []string
Member placement.MemberContext Member placement.MemberContext
Crawler placement.CrawlerSearchFn Crawler placement.CrawlerSearchFn
Limit int // per keyword; 0 = default Limit int // per keyword; 0 = default
@ -56,6 +59,7 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
relaxed := map[string]placement.ScanCandidate{} relaxed := map[string]placement.ScanCandidate{}
total := len(keywords) total := len(keywords)
pathLabel := input.Member.DiscoverPathLabel() pathLabel := input.Member.DiscoverPathLabel()
topicTerms := missionTopicMatchTerms(input.SeedQuery, input.Label, input.TopicHints)
var lastErr error var lastErr error
keywordsAttempted := 0 keywordsAttempted := 0
@ -114,6 +118,9 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
Priority: PriorityLabel(score), Priority: PriorityLabel(score),
} }
if input.MissionScan { if input.MissionScan {
if !missionPostMatchesTopic(candidate, topicTerms) {
continue
}
if PassesMissionQualityCandidate( if PassesMissionQualityCandidate(
post.Text, post.LikeCount, post.ReplyCount, score, post.Text, post.LikeCount, post.ReplyCount, score,
post.AuthorVerified, post.FollowerCount, input.Exclusions, post.AuthorVerified, post.FollowerCount, input.Exclusions,

View File

@ -6,12 +6,17 @@ import (
"sort" "sort"
"strings" "strings"
libthreads "haixun-backend/internal/library/threadsapi"
"haixun-backend/internal/library/websearch" "haixun-backend/internal/library/websearch"
) )
const ( const (
maxAccountDiscoverQueries = 2 // Single web-search query per supplement pass — fewer API calls, same recall
MaxSimilarAccounts = 5 // 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._]+)`) var threadsProfileRE = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([a-zA-Z0-9._]+)`)
@ -23,11 +28,12 @@ var reservedUsernames = map[string]struct{}{
} }
type SimilarAccount struct { type SimilarAccount struct {
Username string `json:"username"` Username string `json:"username"`
Reason string `json:"reason"` Reason string `json:"reason"`
Source string `json:"source"` Source string `json:"source"`
Confidence string `json:"confidence"` MatchedSource []string `json:"matchedSource,omitempty"`
ProfileURL string `json:"profileUrl"` Confidence string `json:"confidence"`
ProfileURL string `json:"profileUrl"`
} }
type DiscoverAccountsInput struct { type DiscoverAccountsInput struct {
@ -37,10 +43,11 @@ type DiscoverAccountsInput struct {
} }
type accountCandidate struct { type accountCandidate struct {
username string username string
score int score int
reason string reason string
source string source string
permalink string
} }
func DiscoverSimilarAccounts(ctx context.Context, client websearch.Client, input DiscoverAccountsInput) ([]SimilarAccount, error) { 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) key := strings.ToLower(username)
prev, ok := seen[key] prev, ok := seen[key]
permalink := strings.TrimSpace(item.URL)
if !ok || weight > prev.score { if !ok || weight > prev.score {
seen[key] = accountCandidate{ seen[key] = accountCandidate{
username: username, username: username,
score: weight, score: weight,
reason: reason, reason: reason,
source: "web", source: "web",
permalink: permalink,
} }
} else if ok { } else if ok {
prev.score += 1 prev.score += 1
if prev.permalink == "" && permalink != "" {
prev.permalink = permalink
}
seen[key] = prev seen[key] = prev
} }
} }
@ -111,49 +123,41 @@ func DiscoverSimilarAccounts(ctx context.Context, client websearch.Client, input
accounts := make([]SimilarAccount, 0, len(out)) accounts := make([]SimilarAccount, 0, len(out))
for _, item := range out { for _, item := range out {
profileURL := libthreads.ProfileURLFromPermalink(item.permalink, item.username)
accounts = append(accounts, SimilarAccount{ accounts = append(accounts, SimilarAccount{
Username: item.username, Username: item.username,
Reason: item.reason, Reason: item.reason,
Source: item.source, Source: item.source,
Confidence: accountConfidence(item.score), MatchedSource: []string{item.source},
ProfileURL: "https://www.threads.net/@" + item.username, Confidence: accountConfidence(item.score),
ProfileURL: profileURL,
}) })
} }
return accounts, nil return accounts, nil
} }
func buildAccountDiscoverQueries(seed, brief string, pillars []string) []string { func buildAccountDiscoverQueries(seed, brief string, pillars []string) []string {
quoted := `"` + seed + `"` seed = strings.TrimSpace(seed)
queries := []string{ if seed == "" {
`site:threads.net ` + quoted, return nil
`threads ` + quoted + ` 創作者`,
} }
quoted := `"` + seed + `"`
query := `site:threads.net ` + quoted
if hint := strings.TrimSpace(brief); len([]rune(hint)) >= 4 && len([]rune(hint)) <= 24 { 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 { for _, pillar := range pillars {
pillar = strings.TrimSpace(pillar) pillar = strings.TrimSpace(pillar)
if len([]rune(pillar)) >= 4 && len(queries) < maxAccountDiscoverQueries+1 { if len([]rune(pillar)) >= 4 {
queries = append(queries, `site:threads.net "`+pillar+`"`) query += ` "` + 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 {
break break
} }
} }
return unique query = strings.TrimSpace(query)
if query == "" {
return nil
}
return []string{query}
} }
func extractUsernames(blob string) []string { func extractUsernames(blob string) []string {

View File

@ -1,6 +1,9 @@
package viral package viral
import "testing" import (
"strings"
"testing"
)
func TestExtractUsernames(t *testing.T) { func TestExtractUsernames(t *testing.T) {
blob := `See https://www.threads.net/@creator_one and threads.com/@creator_two/posts/abc` 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) { func TestBuildAccountDiscoverQueriesSingleCombinedQuery(t *testing.T) {
queries := buildAccountDiscoverQueries("轉職", "想找語錄", []string{"支柱A", "支柱B", "支柱C"}) queries := buildAccountDiscoverQueries("轉職", "想找語錄", []string{"職場語錄", "支柱B"})
if len(queries) > maxAccountDiscoverQueries { if len(queries) != 1 {
t.Fatalf("expected at most %d queries, got %d", maxAccountDiscoverQueries, len(queries)) 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])
} }
} }

View File

@ -70,3 +70,49 @@ func TestRunDiscover_missionRelaxedFallbackWithoutVerified(t *testing.T) {
t.Fatal("verified should remain false when API omits it") 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)
}
}

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
"haixun-backend/internal/library/placement" "haixun-backend/internal/library/placement"
libthreads "haixun-backend/internal/library/threadsapi"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity" missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
) )
@ -26,9 +27,10 @@ func EnrichSimilarAccounts(existing []missionentity.SimilarAccount, posts []plac
} }
type authorScore struct { type authorScore struct {
username string username string
score int score int
text string text string
permalink string
} }
authors := map[string]authorScore{} authors := map[string]authorScore{}
for _, post := range posts { for _, post := range posts {
@ -43,6 +45,9 @@ func EnrichSimilarAccounts(existing []missionentity.SimilarAccount, posts []plac
if prev.text == "" && strings.TrimSpace(post.Text) != "" { if prev.text == "" && strings.TrimSpace(post.Text) != "" {
prev.text = strings.TrimSpace(post.Text) prev.text = strings.TrimSpace(post.Text)
} }
if prev.permalink == "" {
prev.permalink = strings.TrimSpace(post.Permalink)
}
authors[key] = prev authors[key] = prev
} }
ranked := make([]authorScore, 0, len(authors)) ranked := make([]authorScore, 0, len(authors))
@ -70,11 +75,12 @@ func EnrichSimilarAccounts(existing []missionentity.SimilarAccount, posts []plac
conf = "high" conf = "high"
} }
byUser[key] = missionentity.SimilarAccount{ byUser[key] = missionentity.SimilarAccount{
Username: item.username, Username: item.username,
Reason: reason, Reason: reason,
Source: "scan", Source: "scan",
Confidence: conf, MatchedSource: []string{"scan"},
ProfileURL: "https://www.threads.net/@" + item.username, Confidence: conf,
ProfileURL: libthreads.ProfileURLFromPermalink(item.permalink, item.username),
} }
order = append(order, key) order = append(order, key)
} }
@ -116,12 +122,17 @@ func AccountTagsFromSimilar(accounts []missionentity.SimilarAccount, max int) []
func ToEntitySimilarAccounts(items []SimilarAccount) []missionentity.SimilarAccount { func ToEntitySimilarAccounts(items []SimilarAccount) []missionentity.SimilarAccount {
out := make([]missionentity.SimilarAccount, 0, len(items)) out := make([]missionentity.SimilarAccount, 0, len(items))
for _, item := range items { for _, item := range items {
matched := item.MatchedSource
if len(matched) == 0 && item.Source != "" {
matched = []string{item.Source}
}
out = append(out, missionentity.SimilarAccount{ out = append(out, missionentity.SimilarAccount{
Username: item.Username, Username: item.Username,
Reason: item.Reason, Reason: item.Reason,
Source: item.Source, Source: item.Source,
Confidence: item.Confidence, MatchedSource: matched,
ProfileURL: item.ProfileURL, Confidence: item.Confidence,
ProfileURL: item.ProfileURL,
}) })
} }
return out return out

View File

@ -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)
}

View File

@ -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
}

View File

@ -1,9 +1,13 @@
package viral package viral
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort"
"strings" "strings"
"haixun-backend/internal/library/placement"
) )
type MissionInspireInput struct { type MissionInspireInput struct {
@ -17,6 +21,7 @@ type MissionInspireInput struct {
PersonaPillars []string PersonaPillars []string
RecentMissionLabels []string RecentMissionLabels []string
RecentSeedQueries []string RecentSeedQueries []string
UserKeyword string
TrendSnippets []MissionInspireTrendSnippet TrendSnippets []MissionInspireTrendSnippet
WebSearchProvider string WebSearchProvider string
LLMOnly bool LLMOnly bool
@ -29,6 +34,16 @@ type MissionInspireTrendSnippet struct {
URL string URL string
} }
type MissionInspireThreadsInput struct {
Keyword string
PersonaBrief string
StyleBenchmark string
Pillars []string
Questions []string
Member placement.MemberContext
Crawler placement.CrawlerSearchFn
}
type MissionInspireOutput struct { type MissionInspireOutput struct {
Label string Label string
SeedQuery string SeedQuery string
@ -38,10 +53,10 @@ type MissionInspireOutput struct {
} }
func BuildMissionInspireSystemPrompt() string { func BuildMissionInspireSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 拷貝忍者的靈感骰子顧問根據近期網路熱搜Google Trends 訊號與創作者人設產出一組**全新**拷貝任務草稿 return strings.TrimSpace(`你是 Threads 拷貝忍者的靈感骰子顧問根據近期 Threads 熱門貼文網路搜尋訊號與創作者人設產出一組**全新**拷貝任務草稿
規則 規則
1. 近期趨勢訊號有內容從中挑一個適合在 Threads 海巡的方向不要編造不存在的時事 1. 近期趨勢訊號有內容從中挑一個適合在 Threads 海巡的方向不要編造不存在的時事或熱門貼文
2. 若趨勢訊號為空未連線網路搜尋**必須**改依人設受眾痛點與常見 Threads 討論型態推測近期可能被搜尋的話題並在 trendReason 說明推測理由不要假裝有外部熱搜來源 2. 若趨勢訊號為空未連線網路搜尋**必須**改依人設受眾痛點與常見 Threads 討論型態推測近期可能被搜尋的話題並在 trendReason 說明推測理由不要假裝有外部熱搜來源
3. label任務名稱618 像企劃案標題不要標點堆疊 3. label任務名稱618 像企劃案標題不要標點堆疊
4. seedQuery種子關鍵字近期熱詞26 個詞用頓號或空格分隔適合當 Threads 搜尋起點 4. seedQuery種子關鍵字近期熱詞26 個詞用頓號或空格分隔適合當 Threads 搜尋起點
@ -85,6 +100,11 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
b.WriteString(strings.Join(in.PersonaPillars, "、")) b.WriteString(strings.Join(in.PersonaPillars, "、"))
b.WriteString("\n") 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 { if len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 {
b.WriteString("【近期已做過的任務(請避開)】\n") b.WriteString("【近期已做過的任務(請避開)】\n")
for _, label := range in.RecentMissionLabels { for _, label := range in.RecentMissionLabels {
@ -103,11 +123,11 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
} }
} }
if in.LLMOnly { if in.LLMOnly {
b.WriteString("【模式】未設定 Web Search API key請純依人設與受眾推測靈感勿假裝有 Google 熱搜)\n") b.WriteString("【模式】未取得 Threads/API/搜尋素材,請純依人設、使用者關鍵字與受眾推測靈感(勿假裝有外部熱搜)\n")
} else if provider := strings.TrimSpace(in.WebSearchProvider); provider != "" { } else if provider := strings.TrimSpace(in.WebSearchProvider); provider != "" {
b.WriteString("【趨勢來源】") b.WriteString("【趨勢來源】")
b.WriteString(provider) b.WriteString(provider)
b.WriteString(" 網路搜尋\n") b.WriteString("\n")
} }
b.WriteString("【近期趨勢訊號】\n") b.WriteString("【近期趨勢訊號】\n")
if len(in.TrendSnippets) == 0 { if len(in.TrendSnippets) == 0 {
@ -148,6 +168,152 @@ func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
return queries 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) { func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) {
payload, err := extractCopyMapJSON(raw) payload, err := extractCopyMapJSON(raw)
if err != nil { if err != nil {

View File

@ -25,7 +25,7 @@ func TestBuildMissionInspireUserPromptLLMOnly(t *testing.T) {
PersonaBrief: "職場焦慮", PersonaBrief: "職場焦慮",
LLMOnly: true, 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) t.Fatalf("expected llm-only hint in prompt: %s", prompt)
} }
if !strings.Contains(prompt, "無外部趨勢結果") { if !strings.Contains(prompt, "無外部趨勢結果") {

View File

@ -18,19 +18,20 @@ type MissionResearchMap struct {
} }
func BuildMissionResearchMapSystemPrompt() string { func BuildMissionResearchMapSystemPrompt() string {
return strings.TrimSpace(`你是 Threads 爆款對標研究顧問目標幫創作者找到近期熱門高互動值得仿寫的話題與搜尋方向 return strings.TrimSpace(`你是 Threads 拷貝任務的延伸知識與相似帳號研究顧問目標把使用者的完整意圖延伸成可產文的知識地圖並提供適合人工參考的相似帳號搜尋方向不要把題目壓扁成泛泛短詞
規則 規則
1. audienceSummary必填24 句描述受眾是誰年齡情境痛點會在 Threads 搜什麼不要只寫人設本人 1. audienceSummary必填24 句描述受眾是誰年齡情境痛點會在 Threads 搜什麼不要只寫人設本人
2. 聚焦最近會在 Threads 被搜被討論的話題不要寫成學術報告 2. 必須保留使用者原始題目的核心主詞情境與目的例如備孕男生要吃什麼保健品不可簡化成老公吃什麼
3. contentGoal找到近期互動佳結構可模仿的爆款貼文 3. contentGoal用延伸知識與人設產出可發的 Threads 內容爆款貼文只作為可選參考不是必經來源
4. pillars可模仿方向語錄型故事型清單型等至少 4 **必須是字串陣列**不要用 {title:...} 物件 4. pillars延伸知識支柱至少 4 **必須是字串陣列**每項要能支撐一篇內容例如營養素檢查生活習慣常見迷思
5. questions受眾會搜的短問題5+ 字串陣列 5. questions受眾會搜尋或想問的完整問題5+ 字串陣列需保留主詞與情境
6. exclusions不要模仿的內容至少 4 字串陣列 6. exclusions不要寫的內容至少 4 包含過度醫療承諾偏方恐嚇與題目無關的家庭日常
7. suggestedTags68 像真人會在 Threads 搜尋框打的字 7. suggestedTags68 搜尋查詢不是短 hashtag
- 每個含 tag, reason, searchIntent痛點|知識|經驗|對比|工具|語錄, searchType短詞|情境|語錄 - 每個含 tag, reason, searchIntent痛點|知識|經驗|對比|工具|帳號, searchType查詢|情境|帳號
- tag 210 不要標點不要完整句子不要像文章標題 - tag 622 必須保留核心意圖可以像真人搜尋句例如備孕男生保健品精蟲品質吃什麼男性備孕葉酸鋅
8. benchmarkNotes怎樣算值得仿的爆款互動hook 清楚 - 不要輸出老公吃什麼多閱讀這種失去主題的泛詞
8. benchmarkNotes整理延伸知識重點與人工看相似帳號時的觀察方向可列 35 個重點
9. 繁體中文只回傳 JSON audienceSummary`) 9. 繁體中文只回傳 JSON audienceSummary`)
} }
@ -68,7 +69,7 @@ func BuildMissionResearchMapUserPrompt(in CopyResearchMapInput) string {
} }
b.WriteString("\n") b.WriteString("\n")
} }
b.WriteString("\n請產出拷貝任務研究地圖 JSON(必填 audienceSummary 與 suggestedTags 陣列)。") b.WriteString("\n請產出拷貝任務研究地圖 JSON。請把 suggestedTags 當成完整搜尋查詢,不要壓成短 hashtag。")
return b.String() return b.String()
} }

View File

@ -1,6 +1,9 @@
package viral package viral
import "testing" import (
"strings"
"testing"
)
func TestParseMissionResearchMapOutput_ObjectPillars(t *testing.T) { func TestParseMissionResearchMapOutput_ObjectPillars(t *testing.T) {
raw := `{ raw := `{
@ -53,3 +56,13 @@ func TestParseMissionResearchMapOutput_StringSuggestedTags(t *testing.T) {
t.Fatalf("expected 4 tags, got %#v", out.SuggestedTags) 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")
}
}

View File

@ -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
}
}

View File

@ -2,10 +2,13 @@ package viral
import ( import (
"fmt" "fmt"
"math"
"sort" "sort"
"strings" "strings"
"haixun-backend/internal/library/clock"
"haixun-backend/internal/library/placement" "haixun-backend/internal/library/placement"
libthreads "haixun-backend/internal/library/threadsapi"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity" missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
) )
@ -17,11 +20,63 @@ const (
RefVerifiedMinBestLikes = 10 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 { type ReferenceAccountInput struct {
SeedQuery string SeedQuery string
Label string Label string
Posts []placement.ScanCandidate Posts []placement.ScanCandidate
Limit int Limit int
ExcludedUsernames []string
} }
type referenceAuthorAgg struct { type referenceAuthorAgg struct {
@ -33,8 +88,10 @@ type referenceAuthorAgg struct {
bestLikes int bestLikes int
bestReplies int bestReplies int
postCount int postCount int
topicHits int
sampleText string sampleText string
sampleSearchTag string sampleSearchTag string
samplePermalink string
} }
// BuildReferenceAccountsFromScan lists authors from patrol posts that match the // BuildReferenceAccountsFromScan lists authors from patrol posts that match the
@ -54,12 +111,20 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool
limit = MaxSimilarAccounts limit = MaxSimilarAccounts
} }
byUser := map[string]referenceAuthorAgg{} 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 { for _, post := range in.Posts {
user := strings.TrimSpace(post.Author) user := strings.TrimSpace(post.Author)
if user == "" || !isValidUsername(user) { if user == "" || !isValidUsername(user) {
continue continue
} }
if !postTopicRelevant(post, in.SeedQuery, in.Label) { if isExcluded(in.ExcludedUsernames, user) {
continue
}
hits := topicTopicHits(post, terms)
if hits == 0 {
continue continue
} }
if strictQuality { if strictQuality {
@ -85,6 +150,9 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool
} }
prev.postCount++ prev.postCount++
prev.totalEngagement += post.EngagementScore prev.totalEngagement += post.EngagementScore
if hits > prev.topicHits {
prev.topicHits = hits
}
if post.EngagementScore > prev.bestEngagement { if post.EngagementScore > prev.bestEngagement {
prev.bestEngagement = post.EngagementScore prev.bestEngagement = post.EngagementScore
prev.bestLikes = post.LikeCount prev.bestLikes = post.LikeCount
@ -95,6 +163,7 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool
} }
prev.sampleText = text prev.sampleText = text
prev.sampleSearchTag = strings.TrimSpace(post.SearchTag) prev.sampleSearchTag = strings.TrimSpace(post.SearchTag)
prev.samplePermalink = strings.TrimSpace(post.Permalink)
} }
byUser[key] = prev byUser[key] = prev
} }
@ -106,6 +175,13 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool
} }
} }
sort.Slice(ranked, func(i, j int) 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 { if ranked[i].verified != ranked[j].verified {
return ranked[i].verified return ranked[i].verified
} }
@ -133,8 +209,12 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool
Username: item.username, Username: item.username,
Reason: formatReferenceReason(item), Reason: formatReferenceReason(item),
Source: "scan", Source: "scan",
MatchedSource: []string{"scan"},
Confidence: conf, 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, AuthorVerified: item.verified,
FollowerCount: item.followerCount, FollowerCount: item.followerCount,
EngagementScore: item.bestEngagement, EngagementScore: item.bestEngagement,
@ -164,28 +244,53 @@ func qualifiesReferenceAuthor(item referenceAuthorAgg, strictQuality bool) bool
} }
func postTopicRelevant(post placement.ScanCandidate, seed, label string) bool { func postTopicRelevant(post placement.ScanCandidate, seed, label string) bool {
text := strings.ToLower(strings.TrimSpace(post.Text)) return topicTopicHits(post, normalisedTopicTerms(seed, label)) > 0
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
} }
func topicTerms(seed, label string) []string { // topicTopicHits counts how many normalised topic terms appear (case-folded
out := []string{} // substring match against the post's text and search tag). Returning a hit
if s := strings.TrimSpace(seed); s != "" { // count (instead of a boolean) lets the ranking weight reward posts that are
out = append(out, s) // 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 != "" { text := strings.ToLower(strings.TrimSpace(post.Text))
out = append(out, l) 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 return out
} }

View File

@ -16,11 +16,13 @@ type SuggestedTag struct {
SearchType string `json:"searchType,omitempty"` 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 { func PickDefaultSelectedTags(tags []SuggestedTag) []string {
short := []string{} short := []string{}
scenario := []string{} query := []string{}
quote := []string{} contextual := []string{}
account := []string{} account := []string{}
for _, item := range tags { for _, item := range tags {
@ -34,17 +36,19 @@ func PickDefaultSelectedTags(tags []SuggestedTag) []string {
continue continue
} }
switch st { switch st {
case "短詞": case "查詢", "情境":
short = append(short, tag) query = append(query, tag)
case "情境": case "短詞", "語錄":
scenario = append(scenario, tag) if len([]rune(tag)) >= 6 {
case "語錄": query = append(query, tag)
quote = append(quote, tag) } else {
short = append(short, tag)
}
default: default:
if len([]rune(tag)) <= 4 { if len([]rune(tag)) <= 4 {
short = append(short, tag) short = append(short, tag)
} else { } else {
scenario = append(scenario, tag) contextual = append(contextual, tag)
} }
} }
} }
@ -62,20 +66,14 @@ func PickDefaultSelectedTags(tags []SuggestedTag) []string {
seen[tag] = struct{}{} seen[tag] = struct{}{}
out = append(out, tag) out = append(out, tag)
} }
for _, tag := range short { for _, tag := range query {
if len(out) >= 2 {
break
}
add(tag)
}
for _, tag := range scenario {
if len(out) >= 4 { if len(out) >= 4 {
break break
} }
add(tag) add(tag)
} }
for _, tag := range quote { for _, tag := range contextual {
if len(out) >= 5 { if len(out) >= 4 {
break break
} }
add(tag) add(tag)
@ -86,13 +84,19 @@ func PickDefaultSelectedTags(tags []SuggestedTag) []string {
} }
add(tag) add(tag)
} }
for _, tag := range short { for _, tag := range query {
if len(out) >= DefaultSelectedTagCount { if len(out) >= DefaultSelectedTagCount {
break break
} }
add(tag) 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 { if len(out) >= DefaultSelectedTagCount {
break break
} }

View File

@ -27,3 +27,20 @@ func TestPickDefaultSelectedTags(t *testing.T) {
seen[tag] = struct{}{} 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)
}
}

View File

@ -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
}

View File

@ -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(`<!doctype html><html><head><title>備孕指南</title></head><body><article><h1>備孕指南</h1><p>葉酸與鋅很重要,作息要穩定。</p></article></body></html>`))
}))
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)
}
}

View File

@ -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 + ""
}

View File

@ -117,11 +117,12 @@ func (a *braveAdapter) Search(ctx context.Context, opts SearchOptions) (SearchRe
mode = libbrave.ModeThreadsDiscover mode = libbrave.ModeThreadsDiscover
} }
res, err := a.client.Search(ctx, libbrave.SearchOptions{ res, err := a.client.Search(ctx, libbrave.SearchOptions{
Query: opts.Query, Query: opts.Query,
Limit: opts.Limit, Limit: opts.Limit,
Mode: mode, Mode: mode,
Country: firstNonEmpty(opts.Country, a.country), Country: firstNonEmpty(opts.Country, a.country),
SearchLang: firstNonEmpty(opts.SearchLang, a.searchLang), SearchLang: firstNonEmpty(opts.SearchLang, a.searchLang),
StartPublishedDate: opts.StartPublishedDate,
}) })
return toBraveResponse(res.Query, res.Status, a.provider, res.Results, err) return toBraveResponse(res.Query, res.Status, a.provider, res.Results, err)
} }

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -7,7 +7,10 @@ import (
app "haixun-backend/internal/library/errors" app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code" "haixun-backend/internal/library/errors/code"
libcopy "haixun-backend/internal/library/copymission"
libkg "haixun-backend/internal/library/knowledge"
libmatrix "haixun-backend/internal/library/matrix" libmatrix "haixun-backend/internal/library/matrix"
libweb "haixun-backend/internal/library/webpage"
libprompt "haixun-backend/internal/library/prompt" libprompt "haixun-backend/internal/library/prompt"
"haixun-backend/internal/library/style8d" "haixun-backend/internal/library/style8d"
domai "haixun-backend/internal/model/ai/domain/usecase" domai "haixun-backend/internal/model/ai/domain/usecase"
@ -43,18 +46,22 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
if err != nil { if err != nil {
return nil, err 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) { mission.Status != string(missionentity.StatusDrafted) {
return nil, app.For(code.Persona).ResInvalidState("請先完成海巡再產出內容矩陣") return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣")
} }
if len(mission.SelectedTags) == 0 { if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
return nil, app.For(code.Persona).InputMissingRequired("請先選擇海巡標籤") return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖")
} }
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := ensureNoActiveMatrixJob(l.ctx, l.svcCtx, missionID); err != nil {
return nil, err
}
if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
} }
@ -79,12 +86,33 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
return nil, err return nil, err
} }
samples := matrixSamplesFromScanPosts(posts) 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.AudienceSummary,
mission.ResearchMap.ContentGoal, mission.ResearchMap.ContentGoal,
mission.ResearchMap.Questions, mission.ResearchMap.Questions,
mission.ResearchMap.Pillars, mission.ResearchMap.Pillars,
mission.ResearchMap.Exclusions, mission.ResearchMap.Exclusions,
knowledgeItems,
researchItemsBlock,
referenceMarkdown,
) )
userPrompt, err := libmatrix.BuildCopyUserPrompt(libmatrix.CopyGenerateInput{ userPrompt, err := libmatrix.BuildCopyUserPrompt(libmatrix.CopyGenerateInput{
Count: count, Count: count,
@ -110,7 +138,7 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
if err != nil { if err != nil {
return nil, err 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, Provider: providerID,
Model: credential.Model, Model: credential.Model,
Credential: domai.Credential{ Credential: domai.Credential{
@ -120,11 +148,7 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
Messages: []domai.Message{ Messages: []domai.Message{
{Role: "user", Content: userPrompt}, {Role: "user", Content: userPrompt},
}, },
}) }, count)
if err != nil {
return nil, err
}
parsed, err := libmatrix.ParseGenerateOutput(result.Text)
if err != nil { if err != nil {
return nil, app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error()) return nil, app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error())
} }
@ -169,6 +193,17 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
}, nil }, 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 { func matrixSamplesFromScanPosts(posts []scanpostdomain.ScanPostSummary) string {
samples := make([]libmatrix.ViralPostSample, 0, len(posts)) samples := make([]libmatrix.ViralPostSample, 0, len(posts))
for _, post := range posts { for _, post := range posts {

View File

@ -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
}

View File

@ -25,12 +25,13 @@ func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext)
return &InspireCopyMissionLogic{ctx: ctx, svcCtx: svcCtx} 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) tenantID, uid, err := actorFrom(l.ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
personaID := strings.TrimSpace(req.PersonaID) personaID := strings.TrimSpace(req.PersonaID)
userKeyword := strings.TrimSpace(req.Keyword)
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -59,25 +60,42 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi
trendSnippets := []libviral.MissionInspireTrendSnippet{} trendSnippets := []libviral.MissionInspireTrendSnippet{}
webSearchProvider := "" webSearchProvider := ""
trendSourceLabel := ""
llmOnly := true llmOnly := true
research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) 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) memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
if memberErr == nil { if memberErr == nil {
webClient := websearch.New(memberCtx.WebSearchConfig()) trendSnippets = libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{
trendSnippets = libviral.CollectMissionInspireTrends( Keyword: userKeyword,
l.ctx, PersonaBrief: persona.Brief,
webClient, StyleBenchmark: persona.StyleBenchmark,
memberCtx, Pillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
persona.Brief, Questions: append([]string(nil), persona.CopyResearchMap.Questions...),
persona.StyleBenchmark, Member: memberCtx,
) Crawler: l.makeCrawlerSearchFn(tenantID, uid),
webSearchProvider = memberCtx.WebSearchProviderLabel() })
llmOnly = len(trendSnippets) == 0 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) credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
if err != nil { if err != nil {
@ -110,8 +128,9 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi
PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...), PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
RecentMissionLabels: recentLabels, RecentMissionLabels: recentLabels,
RecentSeedQueries: recentSeeds, RecentSeedQueries: recentSeeds,
UserKeyword: userKeyword,
TrendSnippets: trendSnippets, TrendSnippets: trendSnippets,
WebSearchProvider: webSearchProvider, WebSearchProvider: trendSourceLabel,
LLMOnly: llmOnly, LLMOnly: llmOnly,
}), }),
}, },
@ -136,9 +155,12 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi
}) })
} }
message := "已依近期趨勢產出任務靈感,可微調後建立任務" message := "已依近期 Threads 熱門素材產出任務靈感,可微調後建立任務"
if !webSearchUsed { if webSearchUsed {
message = "未設定搜尋 API key已改由 LLM 依人設推測靈感;補上 BraveExa key 可取得更貼近熱搜的結果" message = "已依近期網路趨勢產出任務靈感,可微調後建立任務"
}
if len(trendSnippets) == 0 {
message = "未取得外部趨勢素材,已改由 LLM 依人設與關鍵字推測靈感;同步 Chrome Session、完成 Threads API 或補上 BraveExa key 可更貼近熱搜"
} }
return &types.CopyMissionInspirationData{ return &types.CopyMissionInspirationData{
@ -152,3 +174,17 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi
Message: message, Message: message,
}, nil }, 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)
}
}

View File

@ -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,
}
}

View File

@ -64,6 +64,14 @@ func toMissionPatch(req *types.UpdateCopyMissionReq) missiondomain.MissionPatch
patch.ExclusionsSet = true patch.ExclusionsSet = true
patch.Exclusions = append([]string(nil), req.Exclusions...) 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 { if req.BenchmarkNotes != nil {
patch.BenchmarkNotes = req.BenchmarkNotes patch.BenchmarkNotes = req.BenchmarkNotes
} }
@ -74,6 +82,19 @@ func toMissionPatch(req *types.UpdateCopyMissionReq) missiondomain.MissionPatch
return patch 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 { func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData {
tags := make([]types.CopySuggestedTagData, 0, len(item.ResearchMap.SuggestedTags)) tags := make([]types.CopySuggestedTagData, 0, len(item.ResearchMap.SuggestedTags))
for _, tag := range item.ResearchMap.SuggestedTags { for _, tag := range item.ResearchMap.SuggestedTags {
@ -90,7 +111,11 @@ func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData
Username: acc.Username, Username: acc.Username,
Reason: acc.Reason, Reason: acc.Reason,
Source: acc.Source, Source: acc.Source,
MatchedSource: append([]string(nil), acc.MatchedSource...),
Confidence: acc.Confidence, Confidence: acc.Confidence,
Status: acc.Status,
TopicRelevance: acc.TopicRelevance,
LastSeenAt: acc.LastSeenAt,
ProfileUrl: acc.ProfileURL, ProfileUrl: acc.ProfileURL,
AuthorVerified: acc.AuthorVerified, AuthorVerified: acc.AuthorVerified,
FollowerCount: acc.FollowerCount, FollowerCount: acc.FollowerCount,
@ -100,6 +125,19 @@ func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData
PostCount: acc.PostCount, 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{ return types.CopyMissionData{
ID: item.ID, ID: item.ID,
PersonaID: item.PersonaID, PersonaID: item.PersonaID,
@ -107,14 +145,18 @@ func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData
SeedQuery: item.SeedQuery, SeedQuery: item.SeedQuery,
Brief: item.Brief, Brief: item.Brief,
ResearchMap: types.CopyMissionResearchMapData{ ResearchMap: types.CopyMissionResearchMapData{
AudienceSummary: item.ResearchMap.AudienceSummary, AudienceSummary: item.ResearchMap.AudienceSummary,
ContentGoal: item.ResearchMap.ContentGoal, ContentGoal: item.ResearchMap.ContentGoal,
Questions: append([]string(nil), item.ResearchMap.Questions...), Questions: append([]string(nil), item.ResearchMap.Questions...),
Pillars: append([]string(nil), item.ResearchMap.Pillars...), Pillars: append([]string(nil), item.ResearchMap.Pillars...),
Exclusions: append([]string(nil), item.ResearchMap.Exclusions...), Exclusions: append([]string(nil), item.ResearchMap.Exclusions...),
SuggestedTags: tags, KnowledgeItems: append([]string(nil), item.ResearchMap.KnowledgeItems...),
SimilarAccounts: accounts, SelectedKnowledgeItems: append([]string(nil), item.ResearchMap.SelectedKnowledgeItems...),
BenchmarkNotes: item.ResearchMap.BenchmarkNotes, ResearchItems: toResearchItemData(item.ResearchMap.ResearchItems),
SuggestedTags: tags,
SimilarAccounts: accounts,
AudienceSamples: samples,
BenchmarkNotes: item.ResearchMap.BenchmarkNotes,
}, },
SelectedTags: append([]string(nil), item.SelectedTags...), SelectedTags: append([]string(nil), item.SelectedTags...),
LastScanJobID: item.LastScanJobID, LastScanJobID: item.LastScanJobID,
@ -151,7 +193,11 @@ func toEntityResearchMap(data types.CopyMissionResearchMapData) missionentity.Re
Username: acc.Username, Username: acc.Username,
Reason: acc.Reason, Reason: acc.Reason,
Source: acc.Source, Source: acc.Source,
MatchedSource: append([]string(nil), acc.MatchedSource...),
Confidence: acc.Confidence, Confidence: acc.Confidence,
Status: acc.Status,
TopicRelevance: acc.TopicRelevance,
LastSeenAt: acc.LastSeenAt,
ProfileURL: acc.ProfileUrl, ProfileURL: acc.ProfileUrl,
AuthorVerified: acc.AuthorVerified, AuthorVerified: acc.AuthorVerified,
FollowerCount: acc.FollowerCount, FollowerCount: acc.FollowerCount,
@ -161,15 +207,31 @@ func toEntityResearchMap(data types.CopyMissionResearchMapData) missionentity.Re
PostCount: acc.PostCount, 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{ return missionentity.ResearchMap{
AudienceSummary: data.AudienceSummary, AudienceSummary: data.AudienceSummary,
ContentGoal: data.ContentGoal, ContentGoal: data.ContentGoal,
Questions: append([]string(nil), data.Questions...), Questions: append([]string(nil), data.Questions...),
Pillars: append([]string(nil), data.Pillars...), Pillars: append([]string(nil), data.Pillars...),
Exclusions: append([]string(nil), data.Exclusions...), Exclusions: append([]string(nil), data.Exclusions...),
SuggestedTags: tags, KnowledgeItems: append([]string(nil), data.KnowledgeItems...),
SimilarAccounts: accounts, SelectedKnowledgeItems: append([]string(nil), data.SelectedKnowledgeItems...),
BenchmarkNotes: data.BenchmarkNotes, SuggestedTags: tags,
SimilarAccounts: accounts,
AudienceSamples: samples,
BenchmarkNotes: data.BenchmarkNotes,
} }
} }

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -35,6 +35,9 @@ func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob(
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil { if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
return nil, err return nil, err
} }
if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
return nil, err
}
payload := map[string]any{ payload := map[string]any{
"tenant_id": tenantID, "tenant_id": tenantID,

View File

@ -45,6 +45,9 @@ func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(
if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) { if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") 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) post, err := l.svcCtx.ScanPost.GetForPersona(l.ctx, tenantID, uid, personaID, scanPostID)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -35,12 +35,13 @@ func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(
if err != nil { if err != nil {
return nil, err 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) { mission.Status != string(missionentity.StatusDrafted) {
return nil, app.For(code.Persona).ResInvalidState("請先完成海巡再產出內容矩陣") return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣")
} }
if len(mission.SelectedTags) == 0 { if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
return nil, app.For(code.Persona).InputMissingRequired("請先選擇海巡標籤") return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖")
} }
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID) 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) { if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析") 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 count := req.Count
if count <= 0 { if count <= 0 {
@ -60,6 +64,8 @@ func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(
} }
payload := map[string]any{ payload := map[string]any{
"tenant_id": tenantID,
"owner_uid": uid,
"persona_id": personaID, "persona_id": personaID,
"copy_mission_id": missionID, "copy_mission_id": missionID,
"count": count, "count": count,

View File

@ -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
}

View File

@ -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
}

View File

@ -55,4 +55,4 @@ func (l *ListThreadsAccountAiProviderModelsLogic) ListThreadsAccountAiProviderMo
Streams: result.Streams, Streams: result.Streams,
Error: result.Error, Error: result.Error,
}, nil }, nil
} }

View File

@ -16,6 +16,7 @@ type CopyDraft struct {
CopyMissionID string `bson:"copy_mission_id,omitempty"` CopyMissionID string `bson:"copy_mission_id,omitempty"`
ScanPostID string `bson:"scan_post_id,omitempty"` ScanPostID string `bson:"scan_post_id,omitempty"`
DraftType string `bson:"draft_type"` DraftType string `bson:"draft_type"`
MatrixBatchID string `bson:"matrix_batch_id,omitempty"`
SortOrder int `bson:"sort_order,omitempty"` SortOrder int `bson:"sort_order,omitempty"`
Text string `bson:"text"` Text string `bson:"text"`
Angle string `bson:"angle,omitempty"` Angle string `bson:"angle,omitempty"`

View File

@ -12,8 +12,15 @@ type Repository interface {
CreateMany(ctx context.Context, drafts []*entity.CopyDraft) error CreateMany(ctx context.Context, drafts []*entity.CopyDraft) error
Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*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) 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 DeleteByMissionAndType(ctx context.Context, tenantID, ownerUID, personaID, missionID, draftType string) error
DeleteByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID 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) 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) 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
} }

View File

@ -71,6 +71,14 @@ type UpdateRequest struct {
Patch CopyDraftPatch Patch CopyDraftPatch
} }
type DeleteMissionMatrixDraftsRequest struct {
TenantID string
OwnerUID string
PersonaID string
MissionID string
DraftIDs []string
}
type UseCase interface { type UseCase interface {
Create(ctx context.Context, req CreateRequest) (*CopyDraftSummary, error) Create(ctx context.Context, req CreateRequest) (*CopyDraftSummary, error)
CreateMany(ctx context.Context, req CreateManyRequest) ([]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) MarkPublished(ctx context.Context, req MarkPublishedRequest) (*CopyDraftSummary, error)
List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]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) 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 ClearByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error
} }

View File

@ -12,6 +12,7 @@ import (
"go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/options"
"go.mongodb.org/mongo-driver/mongo/writeconcern"
) )
type mongoRepository struct { type mongoRepository struct {
@ -159,6 +160,61 @@ func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, person
return &out, nil 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 { func (r *mongoRepository) DeleteByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error {
if r.collection == nil { if r.collection == nil {
return app.For(code.Persona).DBUnavailable("Mongo is not configured") return app.For(code.Persona).DBUnavailable("Mongo is not configured")
@ -189,6 +245,99 @@ func (r *mongoRepository) DeleteByMissionAndType(ctx context.Context, tenantID,
return err 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) { func (r *mongoRepository) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]entity.CopyDraft, error) {
if r.collection == nil { if r.collection == nil {
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")

View File

@ -5,21 +5,24 @@ import (
"strings" "strings"
"haixun-backend/internal/library/clock" "haixun-backend/internal/library/clock"
libcopy "haixun-backend/internal/library/copymission"
app "haixun-backend/internal/library/errors" app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code" "haixun-backend/internal/library/errors/code"
"haixun-backend/internal/model/copy_draft/domain/entity" "haixun-backend/internal/model/copy_draft/domain/entity"
domrepo "haixun-backend/internal/model/copy_draft/domain/repository" domrepo "haixun-backend/internal/model/copy_draft/domain/repository"
domusecase "haixun-backend/internal/model/copy_draft/domain/usecase" domusecase "haixun-backend/internal/model/copy_draft/domain/usecase"
goredis "github.com/redis/go-redis/v9"
"github.com/google/uuid" "github.com/google/uuid"
) )
type copyDraftUseCase struct { type copyDraftUseCase struct {
repo domrepo.Repository repo domrepo.Repository
redis *goredis.Client
} }
func NewUseCase(repo domrepo.Repository) domusecase.UseCase { func NewUseCase(repo domrepo.Repository, redis *goredis.Client) domusecase.UseCase {
return &copyDraftUseCase{repo: repo} return &copyDraftUseCase{repo: repo, redis: redis}
} }
func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.CopyDraftSummary, error) { func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.CopyDraftSummary, error) {
@ -202,15 +205,121 @@ func (u *copyDraftUseCase) ReplaceMissionMatrix(
if missionID == "" { if missionID == "" {
return nil, app.For(code.Persona).InputMissingRequired("copy_mission_id is required") 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 nil, err
} }
return u.CreateMany(ctx, domusecase.CreateManyRequest{ return out, nil
TenantID: tenantID, }
OwnerUID: ownerUID,
PersonaID: personaID, func buildMatrixDraftEntities(
Drafts: drafts, 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 { func (u *copyDraftUseCase) ClearByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error {

View File

@ -20,35 +20,74 @@ type SuggestedTag struct {
SearchType string `bson:"search_type,omitempty" json:"search_type,omitempty"` 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 { type SimilarAccount struct {
Username string `bson:"username" json:"username"` Username string `bson:"username" json:"username"`
Reason string `bson:"reason,omitempty" json:"reason,omitempty"` Reason string `bson:"reason,omitempty" json:"reason,omitempty"`
Source string `bson:"source,omitempty" json:"source,omitempty"` Source string `bson:"source,omitempty" json:"source,omitempty"`
Confidence string `bson:"confidence,omitempty" json:"confidence,omitempty"` MatchedSource []string `bson:"matched_source,omitempty" json:"matched_source,omitempty"`
ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"` Confidence string `bson:"confidence,omitempty" json:"confidence,omitempty"`
AuthorVerified bool `bson:"author_verified,omitempty" json:"author_verified,omitempty"` Status string `bson:"status,omitempty" json:"status,omitempty"`
FollowerCount int `bson:"follower_count,omitempty" json:"follower_count,omitempty"` TopicRelevance float64 `bson:"topic_relevance,omitempty" json:"topic_relevance,omitempty"`
EngagementScore int `bson:"engagement_score,omitempty" json:"engagement_score,omitempty"` LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
LikeCount int `bson:"like_count,omitempty" json:"like_count,omitempty"` ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"`
ReplyCount int `bson:"reply_count,omitempty" json:"reply_count,omitempty"` AuthorVerified bool `bson:"author_verified,omitempty" json:"author_verified,omitempty"`
PostCount int `bson:"post_count,omitempty" json:"post_count,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 { type ResearchMap struct {
AudienceSummary string `bson:"audience_summary,omitempty" json:"audience_summary,omitempty"` AudienceSummary string `bson:"audience_summary,omitempty" json:"audience_summary,omitempty"`
ContentGoal string `bson:"content_goal,omitempty" json:"content_goal,omitempty"` ContentGoal string `bson:"content_goal,omitempty" json:"content_goal,omitempty"`
Questions []string `bson:"questions,omitempty" json:"questions,omitempty"` Questions []string `bson:"questions,omitempty" json:"questions,omitempty"`
Pillars []string `bson:"pillars,omitempty" json:"pillars,omitempty"` Pillars []string `bson:"pillars,omitempty" json:"pillars,omitempty"`
Exclusions []string `bson:"exclusions,omitempty" json:"exclusions,omitempty"` Exclusions []string `bson:"exclusions,omitempty" json:"exclusions,omitempty"`
SuggestedTags []SuggestedTag `bson:"suggested_tags,omitempty" json:"suggested_tags,omitempty"` KnowledgeItems []string `bson:"knowledge_items,omitempty" json:"knowledge_items,omitempty"`
SimilarAccounts []SimilarAccount `bson:"similar_accounts,omitempty" json:"similar_accounts,omitempty"` SelectedKnowledgeItems []string `bson:"selected_knowledge_items,omitempty" json:"selected_knowledge_items,omitempty"`
BenchmarkNotes string `bson:"benchmark_notes,omitempty" json:"benchmark_notes,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 { func (m ResearchMap) IsEmpty() bool {
return m.AudienceSummary == "" && return m.AudienceSummary == "" &&
m.ContentGoal == "" && m.ContentGoal == "" &&
len(m.Questions) == 0 && len(m.Questions) == 0 &&
len(m.KnowledgeItems) == 0 &&
len(m.SuggestedTags) == 0 len(m.SuggestedTags) == 0
} }

View File

@ -6,6 +6,13 @@ import (
"haixun-backend/internal/model/copy_mission/domain/entity" "haixun-backend/internal/model/copy_mission/domain/entity"
) )
type ResearchItemSummary struct {
Title string
URL string
Snippet string
Query string
}
type SuggestedTagSummary struct { type SuggestedTagSummary struct {
Tag string Tag string
Reason string Reason string
@ -17,7 +24,11 @@ type SimilarAccountSummary struct {
Username string Username string
Reason string Reason string
Source string Source string
MatchedSource []string
Confidence string Confidence string
Status string
TopicRelevance float64
LastSeenAt int64
ProfileURL string ProfileURL string
AuthorVerified bool AuthorVerified bool
FollowerCount int FollowerCount int
@ -27,15 +38,30 @@ type SimilarAccountSummary struct {
PostCount int PostCount int
} }
type AudienceSampleSummary struct {
Username string
SamplePostID string
SampleText string
ReplyLikeCount int
Appearances int
FirstSeenAt int64
LastSeenAt int64
Status string
}
type ResearchMapSummary struct { type ResearchMapSummary struct {
AudienceSummary string AudienceSummary string
ContentGoal string ContentGoal string
Questions []string Questions []string
Pillars []string Pillars []string
Exclusions []string Exclusions []string
SuggestedTags []SuggestedTagSummary KnowledgeItems []string
SimilarAccounts []SimilarAccountSummary SelectedKnowledgeItems []string
BenchmarkNotes string ResearchItems []ResearchItemSummary
SuggestedTags []SuggestedTagSummary
SimilarAccounts []SimilarAccountSummary
AudienceSamples []AudienceSampleSummary
BenchmarkNotes string
} }
type MissionSummary struct { type MissionSummary struct {
@ -64,23 +90,27 @@ type CreateRequest struct {
} }
type MissionPatch struct { type MissionPatch struct {
Label *string Label *string
SeedQuery *string SeedQuery *string
Brief *string Brief *string
AudienceSummary *string AudienceSummary *string
ContentGoal *string ContentGoal *string
QuestionsSet bool QuestionsSet bool
Questions []string Questions []string
PillarsSet bool PillarsSet bool
Pillars []string Pillars []string
ExclusionsSet bool ExclusionsSet bool
Exclusions []string Exclusions []string
BenchmarkNotes *string KnowledgeItemsSet bool
SelectedTagsSet bool KnowledgeItems []string
SelectedTags []string SelectedKnowledgeItemsSet bool
ResearchMap *entity.ResearchMap SelectedKnowledgeItems []string
LastScanJobID *string BenchmarkNotes *string
Status *entity.Status SelectedTagsSet bool
SelectedTags []string
ResearchMap *entity.ResearchMap
LastScanJobID *string
Status *entity.Status
} }
type UpdateRequest struct { type UpdateRequest struct {
@ -95,10 +125,30 @@ type ListResult struct {
List []MissionSummary 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 { type UseCase interface {
List(ctx context.Context, tenantID, ownerUID, personaID string) (*ListResult, error) List(ctx context.Context, tenantID, ownerUID, personaID string) (*ListResult, error)
Create(ctx context.Context, req CreateRequest) (*MissionSummary, error) Create(ctx context.Context, req CreateRequest) (*MissionSummary, error)
Get(ctx context.Context, tenantID, ownerUID, personaID, missionID string) (*MissionSummary, error) Get(ctx context.Context, tenantID, ownerUID, personaID, missionID string) (*MissionSummary, error)
Update(ctx context.Context, req UpdateRequest) (*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 Delete(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error
} }

View File

@ -129,6 +129,12 @@ func (u *missionUseCase) Update(ctx context.Context, req domusecase.UpdateReques
if req.Patch.ExclusionsSet { if req.Patch.ExclusionsSet {
patch["research_map.exclusions"] = cleanStringList(req.Patch.Exclusions) 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 { if req.Patch.BenchmarkNotes != nil {
patch["research_map.benchmark_notes"] = strings.TrimSpace(*req.Patch.BenchmarkNotes) 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 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 { func (u *missionUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error {
if err := requireActor(tenantID, ownerUID, personaID); err != nil { if err := requireActor(tenantID, ownerUID, personaID); err != nil {
return err return err
@ -184,11 +266,16 @@ func toSummary(item *entity.Mission) domusecase.MissionSummary {
} }
accounts := make([]domusecase.SimilarAccountSummary, 0, len(item.ResearchMap.SimilarAccounts)) accounts := make([]domusecase.SimilarAccountSummary, 0, len(item.ResearchMap.SimilarAccounts))
for _, acc := range item.ResearchMap.SimilarAccounts { for _, acc := range item.ResearchMap.SimilarAccounts {
matched := append([]string(nil), acc.MatchedSource...)
accounts = append(accounts, domusecase.SimilarAccountSummary{ accounts = append(accounts, domusecase.SimilarAccountSummary{
Username: acc.Username, Username: acc.Username,
Reason: acc.Reason, Reason: acc.Reason,
Source: acc.Source, Source: acc.Source,
MatchedSource: matched,
Confidence: acc.Confidence, Confidence: acc.Confidence,
Status: acc.Status,
TopicRelevance: acc.TopicRelevance,
LastSeenAt: acc.LastSeenAt,
ProfileURL: acc.ProfileURL, ProfileURL: acc.ProfileURL,
AuthorVerified: acc.AuthorVerified, AuthorVerified: acc.AuthorVerified,
FollowerCount: acc.FollowerCount, FollowerCount: acc.FollowerCount,
@ -198,13 +285,26 @@ func toSummary(item *entity.Mission) domusecase.MissionSummary {
PostCount: acc.PostCount, 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{ return domusecase.MissionSummary{
ID: item.ID, ID: item.ID,
PersonaID: item.PersonaID, PersonaID: item.PersonaID,
Label: item.Label, Label: item.Label,
SeedQuery: item.SeedQuery, SeedQuery: item.SeedQuery,
Brief: item.Brief, Brief: item.Brief,
ResearchMap: toMapSummary(item.ResearchMap, tags, accounts), ResearchMap: toMapSummary(item.ResearchMap, tags, accounts, samples),
SelectedTags: append([]string(nil), item.SelectedTags...), SelectedTags: append([]string(nil), item.SelectedTags...),
LastScanJobID: item.LastScanJobID, LastScanJobID: item.LastScanJobID,
Status: string(item.Status), 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{ return domusecase.ResearchMapSummary{
AudienceSummary: m.AudienceSummary, AudienceSummary: m.AudienceSummary,
ContentGoal: m.ContentGoal, ContentGoal: m.ContentGoal,
Questions: append([]string(nil), m.Questions...), Questions: append([]string(nil), m.Questions...),
Pillars: append([]string(nil), m.Pillars...), Pillars: append([]string(nil), m.Pillars...),
Exclusions: append([]string(nil), m.Exclusions...), Exclusions: append([]string(nil), m.Exclusions...),
SuggestedTags: tags, KnowledgeItems: append([]string(nil), m.KnowledgeItems...),
SimilarAccounts: accounts, SelectedKnowledgeItems: append([]string(nil), m.SelectedKnowledgeItems...),
BenchmarkNotes: m.BenchmarkNotes, 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")
} }
} }

View File

@ -6,6 +6,7 @@ type QueueRepository interface {
Enqueue(ctx context.Context, workerType, jobID string) error Enqueue(ctx context.Context, workerType, jobID string) error
Dequeue(ctx context.Context, workerType string, timeoutSeconds int) (string, error) Dequeue(ctx context.Context, workerType string, timeoutSeconds int) (string, error)
RemoveJob(ctx context.Context, workerType, jobID 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 SetCancelSignal(ctx context.Context, jobID, reason string) error
GetCancelSignal(ctx context.Context, jobID string) (bool, string, error) GetCancelSignal(ctx context.Context, jobID string) (bool, string, error)
ClearCancelSignal(ctx context.Context, jobID string) error ClearCancelSignal(ctx context.Context, jobID string) error

View File

@ -28,4 +28,5 @@ type RunRepository interface {
FindPendingDue(ctx context.Context, now int64, limit int64) ([]*entity.Run, error) FindPendingDue(ctx context.Context, now int64, limit int64) ([]*entity.Run, error)
FindCancelRequestedBefore(ctx context.Context, before 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) FindRunningTimedOut(ctx context.Context, now int64, limit int64) ([]*entity.Run, error)
FindQueuedWithLock(ctx context.Context, limit int64) ([]*entity.Run, error)
} }

View File

@ -96,6 +96,7 @@ type UseCase interface {
EnsurePlacementScanTemplate(ctx context.Context) error EnsurePlacementScanTemplate(ctx context.Context) error
EnsureScanViralTemplate(ctx context.Context) error EnsureScanViralTemplate(ctx context.Context) error
EnsureAnalyzeCopyMissionTemplate(ctx context.Context) error EnsureAnalyzeCopyMissionTemplate(ctx context.Context) error
EnsureExpandCopyMissionGraphTemplate(ctx context.Context) error
EnsureGenerateCopyMatrixTemplate(ctx context.Context) error EnsureGenerateCopyMatrixTemplate(ctx context.Context) error
EnsureGenerateCopyDraftTemplate(ctx context.Context) error EnsureGenerateCopyDraftTemplate(ctx context.Context) error
@ -127,7 +128,9 @@ type UseCase interface {
} }
type MaintenanceResult struct { type MaintenanceResult struct {
EnqueuedPending int EnqueuedPending int
ReapedCancelGrace int ReapedCancelGrace int
ReapedExpiredLocks int ReapedExpiredLocks int
RepairedStuckClaims int
ReapedOrphanedLocks int
} }

View File

@ -243,9 +243,10 @@ func (r *mongoRunRepository) FindPendingDue(ctx context.Context, now int64, limi
} }
cursor, err := r.collection.Find(ctx, bson.M{ cursor, err := r.collection.Find(ctx, bson.M{
"status": enum.RunStatusPending, "status": enum.RunStatusPending,
"scheduled_at": bson.M{ "$or": []bson.M{
"$lte": now, {"scheduled_at": bson.M{"$lte": now, "$ne": nil}},
"$ne": nil, {"scheduled_at": nil},
{"scheduled_at": bson.M{"$exists": false}},
}, },
}, options.Find().SetSort(bson.D{{Key: "scheduled_at", Value: 1}}).SetLimit(limit)) }, options.Find().SetSort(bson.D{{Key: "scheduled_at", Value: 1}}).SetLimit(limit))
if err != nil { if err != nil {
@ -284,6 +285,31 @@ func (r *mongoRunRepository) FindCancelRequestedBefore(ctx context.Context, befo
return items, nil 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) { func (r *mongoRunRepository) FindRunningTimedOut(ctx context.Context, now int64, limit int64) ([]*entity.Run, error) {
if r.collection == nil { if r.collection == nil {
return nil, app.For(code.Job).DBUnavailable("Mongo is not configured") return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")

View File

@ -81,6 +81,13 @@ func (r *redisQueueRepository) RemoveJob(ctx context.Context, workerType, jobID
return r.client.LRem(ctx, queueKey(workerType), 0, jobID).Err() 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 { func (r *redisQueueRepository) SetCancelSignal(ctx context.Context, jobID, reason string) error {
if err := r.requireRedis(); err != nil { if err := r.requireRedis(); err != nil {
return err return err

View File

@ -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"])
}
}

View File

@ -111,13 +111,34 @@ func (u *jobUseCase) enqueueRun(ctx context.Context, run *entity.Run) (*entity.R
if run.ScheduledAt != nil && *run.ScheduledAt > clock.NowUnixNano() { if run.ScheduledAt != nil && *run.ScheduledAt > clock.NowUnixNano() {
return run, nil 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 { if err := u.queue.Enqueue(ctx, run.WorkerType, jobID); err != nil {
return nil, err return nil, err
} }
fromStatus := string(run.Status) fromStatus := string(run.Status)
run.Status = enum.RunStatusQueued patch := *run
updated, err := u.runs.Update(ctx, 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 { 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 return nil, err
} }
_ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusQueued), "job enqueued", nil) _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusQueued), "job enqueued", nil)

View File

@ -51,6 +51,53 @@ func (u *jobUseCase) RunMaintenance(ctx context.Context) (domusecase.Maintenance
result.ReapedCancelGrace++ 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) expiredRuns, err := u.runs.FindRunningTimedOut(ctx, now, 50)
if err != nil { if err != nil {
return result, err return result, err

View File

@ -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)
}
}

View File

@ -3,6 +3,8 @@ package usecase
import ( import (
"context" "context"
"errors" "errors"
"strings"
"haixun-backend/internal/library/clock" "haixun-backend/internal/library/clock"
"haixun-backend/internal/model/job/domain/entity" "haixun-backend/internal/model/job/domain/entity"
"haixun-backend/internal/model/job/domain/enum" "haixun-backend/internal/model/job/domain/enum"
@ -160,6 +162,18 @@ func (m *memoryRunRepo) FindCancelRequestedBefore(_ context.Context, before int6
} }
return nil, nil 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) { func (m *memoryRunRepo) FindRunningTimedOut(_ context.Context, now int64, _ int64) ([]*entity.Run, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@ -312,6 +326,18 @@ func (m *memoryQueueRepo) RemoveJob(_ context.Context, workerType, jobID string)
m.queues[workerType] = filtered m.queues[workerType] = filtered
return nil 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 { func (m *memoryQueueRepo) SetCancelSignal(_ context.Context, jobID, reason string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()

View File

@ -19,7 +19,8 @@ const (
expandGraphTemplateType = "expand-graph" expandGraphTemplateType = "expand-graph"
placementScanTemplateType = "placement-scan" placementScanTemplateType = "placement-scan"
scanViralTemplateType = "scan-viral" scanViralTemplateType = "scan-viral"
analyzeCopyMissionTemplateType = "analyze-copy-mission" analyzeCopyMissionTemplateType = "analyze-copy-mission"
expandCopyMissionGraphTemplateType = "expand-copy-mission-graph"
generateCopyMatrixTemplateType = "generate-copy-matrix" generateCopyMatrixTemplateType = "generate-copy-matrix"
generateCopyDraftTemplateType = "generate-copy-draft" generateCopyDraftTemplateType = "generate-copy-draft"
style8DWorkerType = "node" style8DWorkerType = "node"
@ -89,6 +90,11 @@ func (u *jobUseCase) EnsureAnalyzeCopyMissionTemplate(ctx context.Context) error
return err 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 { func (u *jobUseCase) EnsureGenerateCopyMatrixTemplate(ctx context.Context) error {
_, err := u.templates.Upsert(ctx, generateCopyMatrixTemplate()) _, err := u.templates.Upsert(ctx, generateCopyMatrixTemplate())
return err return err
@ -99,6 +105,32 @@ func (u *jobUseCase) EnsureGenerateCopyDraftTemplate(ctx context.Context) error
return err 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 { func analyzeCopyMissionTemplate() *entity.Template {
return &entity.Template{ return &entity.Template{
Type: analyzeCopyMissionTemplateType, Type: analyzeCopyMissionTemplateType,
@ -626,7 +658,12 @@ func (u *jobUseCase) ClaimNext(ctx context.Context, req domusecase.ClaimNextRequ
}) })
if err != nil { if err != nil {
_ = u.queue.ReleaseLock(ctx, jobID, workerID) _ = 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}) _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusRunning), "worker claimed job", map[string]any{"worker_id": workerID})
return updated, nil 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 { if strings.TrimSpace(req.WorkerID) != "" && run.LockedBy == req.WorkerID {
lockUntil := clock.NowUnixNano() + clock.SecondsToNanos(600) lockUntil := clock.NowUnixNano() + clock.SecondsToNanos(600)
run.LockedUntil = &lockUntil 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}) return u.runs.UpdateIfLocked(ctx, run, req.WorkerID, []enum.RunStatus{enum.RunStatusRunning})
} }

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