Merge branch 'feat/picture'
# Conflicts: # backend/internal/model/job/usecase/usecase.go # backend/internal/model/threads_account/domain/usecase/usecase.go
This commit is contained in:
commit
2dc81e409c
|
|
@ -1,9 +1,11 @@
|
|||
.run
|
||||
.git
|
||||
web/node_modules
|
||||
# web/dist 保留給 deploy/Dockerfile.web.static(本機 make web-build 後 COPY)
|
||||
worker/node_modules
|
||||
.run
|
||||
.cursor
|
||||
**/node_modules
|
||||
backend/bin
|
||||
frontend/dist
|
||||
**/*_test.go
|
||||
**/.DS_Store
|
||||
infra
|
||||
*.md
|
||||
!deploy/**
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
# --- secrets / env(不要 commit 實值)---
|
||||
infra/.env
|
||||
infra/etc/haixun.env
|
||||
/opt/haixun/
|
||||
deploy/.env
|
||||
|
||||
# --- build 產物 ---
|
||||
backend/bin/
|
||||
|
|
|
|||
307
Makefile
307
Makefile
|
|
@ -1,33 +1,32 @@
|
|||
# 巡樓 monorepo Makefile
|
||||
# 兩種模式:
|
||||
# dev - 本機開發(docker 起 Mongo/Redis,go run / vite dev)
|
||||
# prod - 建置產物(前端 dist + linux Go binary),並可部署成 systemd 服務 + nginx
|
||||
# dev - 本機開發(docker 起 Mongo/Redis,go run / vite dev 在本機跑)
|
||||
# docker - 全棧一鍵啟動(Mongo/Redis/gateway/worker/node-worker/web 全在 docker)
|
||||
#
|
||||
# 常用:
|
||||
# make dev-infra # 起本機 Mongo/Redis
|
||||
# make dev-backend # 跑 gateway (:8890)
|
||||
# make dev-frontend # 跑前端 (:5173,proxy 到 :8890)
|
||||
# make build # 產出 frontend/dist + backend/bin/{gateway,worker}
|
||||
# sudo make install # 部署到目標主機(見 infra/README.md)
|
||||
# make up # 一鍵:build + 啟動整套(含前後端、worker、DB)
|
||||
# make down # 停掉整套
|
||||
# make logs # 追全部日誌
|
||||
# make dev-infra # 本機開發:只起 Mongo/Redis
|
||||
# make dev-backend # 本機開發:go run gateway (:8890)
|
||||
# make dev-frontend # 本機開發:vite dev (:5173)
|
||||
|
||||
SHELL := /bin/bash
|
||||
|
||||
# --- 路徑 ---
|
||||
BACKEND_DIR := backend
|
||||
FRONTEND_DIR := frontend
|
||||
INFRA_DIR := infra
|
||||
BIN_DIR := $(BACKEND_DIR)/bin
|
||||
|
||||
# --- 部署目標(install 用)---
|
||||
DEPLOY_ROOT := /opt/haixun
|
||||
WEB_ROOT := /var/www/haixun
|
||||
# --- docker compose(prod 全棧)---
|
||||
COMPOSE_FILE := deploy/docker-compose.yml
|
||||
ENV_FILE := deploy/.env
|
||||
COMPOSE := docker compose -f $(COMPOSE_FILE) --env-file $(ENV_FILE)
|
||||
|
||||
# --- 交叉編譯目標(在 mac 上 build 給 linux 主機)---
|
||||
GOOS ?= linux
|
||||
GOARCH ?= amd64
|
||||
|
||||
# --- docker compose ---
|
||||
COMPOSE := docker compose -f $(INFRA_DIR)/docker-compose.yml --env-file $(INFRA_DIR)/.env
|
||||
# --- docker compose(本地測試;base + dev override + dev env)---
|
||||
DEV_ENV_FILE := deploy/.env.dev
|
||||
DEV_COMPOSE := docker compose -f $(COMPOSE_FILE) -f deploy/docker-compose.dev.yml --env-file $(DEV_ENV_FILE)
|
||||
# go worker 用 gateway 內嵌,dev 不啟動獨立 worker 容器
|
||||
DEV_SERVICES := mongo redis init gateway node-worker web
|
||||
|
||||
# --- dev 用 worker secret(對應 etc/gateway.yaml 的 InternalWorker.Secret)---
|
||||
DEV_WORKER_SECRET := haixun-dev-worker-secret
|
||||
|
|
@ -40,172 +39,140 @@ help: ## 顯示可用指令
|
|||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
$(ENV_FILE):
|
||||
@test -f $(ENV_FILE) || (cp deploy/.env.example $(ENV_FILE) && echo "已從 deploy/.env.example 建立 $(ENV_FILE),請填入密碼與 secret 後再 make up")
|
||||
|
||||
# ============================================================
|
||||
# DEV
|
||||
# DOCKER(全棧一鍵)
|
||||
# ============================================================
|
||||
|
||||
$(INFRA_DIR)/.env:
|
||||
@test -f $(INFRA_DIR)/.env || (cp $(INFRA_DIR)/.env.example $(INFRA_DIR)/.env && echo "已從 .env.example 建立 $(INFRA_DIR)/.env,請視需要修改密碼")
|
||||
.PHONY: up
|
||||
up: $(ENV_FILE) ## [docker] 一鍵啟動整套(build + up,背景)
|
||||
$(COMPOSE) up -d --build
|
||||
@echo ""
|
||||
@echo "=== 啟動中 ==="
|
||||
@echo " 前端: http://localhost:$$(grep -E '^WEB_PORT=' $(ENV_FILE) | cut -d= -f2)"
|
||||
@echo " 健康檢查: curl http://localhost:$$(grep -E '^WEB_PORT=' $(ENV_FILE) | cut -d= -f2)/api/v1/health"
|
||||
@echo " 看狀態: make ps 看日誌: make logs"
|
||||
|
||||
.PHONY: dev-infra
|
||||
dev-infra: $(INFRA_DIR)/.env ## [dev] 起本機 Mongo + Redis (docker)
|
||||
$(COMPOSE) up -d
|
||||
$(COMPOSE) ps
|
||||
|
||||
.PHONY: dev-infra-down
|
||||
dev-infra-down: ## [dev] 停掉本機 Mongo + Redis
|
||||
.PHONY: down
|
||||
down: ## [docker] 停掉整套(保留資料 volume)
|
||||
$(COMPOSE) down
|
||||
|
||||
.PHONY: dev-backend
|
||||
dev-backend: ## [dev] 跑 gateway API (:8890)
|
||||
.PHONY: down-clean
|
||||
down-clean: ## [docker] 停掉整套並刪除資料 volume(清空 Mongo/Redis)
|
||||
$(COMPOSE) down -v
|
||||
|
||||
.PHONY: build
|
||||
build: $(ENV_FILE) ## [docker] 只 build image,不啟動
|
||||
$(COMPOSE) build
|
||||
|
||||
.PHONY: rebuild
|
||||
rebuild: $(ENV_FILE) ## [docker] 無快取重新 build 並重啟
|
||||
$(COMPOSE) build --no-cache
|
||||
$(COMPOSE) up -d
|
||||
|
||||
.PHONY: restart
|
||||
restart: ## [docker] 重啟所有服務
|
||||
$(COMPOSE) restart
|
||||
|
||||
.PHONY: ps
|
||||
ps: ## [docker] 看服務狀態
|
||||
$(COMPOSE) ps
|
||||
|
||||
.PHONY: logs
|
||||
logs: ## [docker] 追全部日誌(Ctrl-C 離開)
|
||||
$(COMPOSE) logs -f
|
||||
|
||||
.PHONY: logs-gateway
|
||||
logs-gateway: ## [docker] 只看 gateway 日誌
|
||||
$(COMPOSE) logs -f gateway
|
||||
|
||||
.PHONY: init
|
||||
init: $(ENV_FILE) ## [docker] 重跑一次 DB 初始化(索引/權限/admin,冪等)
|
||||
$(COMPOSE) run --rm init
|
||||
|
||||
.PHONY: config
|
||||
config: $(ENV_FILE) ## [docker] 驗證 compose 設定
|
||||
$(COMPOSE) config >/dev/null && echo "docker compose config OK"
|
||||
|
||||
# ============================================================
|
||||
# DEV(本地測試,全走 docker)
|
||||
# ============================================================
|
||||
|
||||
.PHONY: dev-up
|
||||
dev-up: ## [dev] 一鍵:本地 docker 測試(gateway 開 :8890,web 開 :8080,go worker 內嵌)
|
||||
$(DEV_COMPOSE) up -d --build $(DEV_SERVICES)
|
||||
@echo ""
|
||||
@echo "=== 本地測試啟動中 ==="
|
||||
@echo " 前端: http://localhost:$$(grep -E '^WEB_PORT=' $(DEV_ENV_FILE) | cut -d= -f2)"
|
||||
@echo " API: http://localhost:8890/api/v1/health"
|
||||
@echo " DevHeaderFallback 已開(可用 X-Tenant-ID / X-UID 模擬登入)"
|
||||
|
||||
.PHONY: dev-down
|
||||
dev-down: ## [dev] 停掉本地 docker(保留資料)
|
||||
$(DEV_COMPOSE) down
|
||||
|
||||
.PHONY: dev-down-clean
|
||||
dev-down-clean: ## [dev] 停掉本地 docker 並清空資料 volume
|
||||
$(DEV_COMPOSE) down -v
|
||||
|
||||
.PHONY: dev-rebuild
|
||||
dev-rebuild: ## [dev] 改 code 後重新 build 並重啟本地 docker
|
||||
$(DEV_COMPOSE) up -d --build $(DEV_SERVICES)
|
||||
|
||||
.PHONY: dev-restart
|
||||
dev-restart: ## [dev] 只重啟(改了 etc/*.yaml 後不用 rebuild,掛載即生效)
|
||||
$(DEV_COMPOSE) restart gateway node-worker
|
||||
|
||||
.PHONY: dev-ps
|
||||
dev-ps: ## [dev] 看本地 docker 狀態
|
||||
$(DEV_COMPOSE) ps
|
||||
|
||||
.PHONY: dev-logs
|
||||
dev-logs: ## [dev] 追本地 docker 日誌
|
||||
$(DEV_COMPOSE) logs -f
|
||||
|
||||
.PHONY: dev-init
|
||||
dev-init: ## [dev] 重跑一次 DB 初始化(索引/權限/admin,冪等)
|
||||
$(DEV_COMPOSE) run --rm init
|
||||
|
||||
.PHONY: dev
|
||||
dev: ## [dev] 本地 docker 測試說明
|
||||
@echo "本地測試(全 docker):"
|
||||
@echo " make dev-up # 一鍵起整套(前端 :8080、API :8890)"
|
||||
@echo " make dev-logs # 看日誌"
|
||||
@echo " make dev-restart # 改 etc/*.yaml 後重啟(免 rebuild)"
|
||||
@echo " make dev-rebuild # 改 Go/前端 code 後重 build"
|
||||
@echo " make dev-down # 停(保留資料)"
|
||||
|
||||
# --- 進階:不進 docker,直接本機 go run / vite(需自備 Go/Node)---
|
||||
.PHONY: local-infra
|
||||
local-infra: ## [local] 只起 Mongo + Redis(docker),其餘本機跑
|
||||
$(DEV_COMPOSE) up -d mongo redis
|
||||
$(DEV_COMPOSE) ps
|
||||
|
||||
.PHONY: local-backend
|
||||
local-backend: ## [local] 本機 go run gateway (:8890)
|
||||
cd $(BACKEND_DIR) && go run . -f etc/gateway.yaml
|
||||
|
||||
.PHONY: dev-worker
|
||||
dev-worker: ## [dev] 跑 Go job worker (:8891)
|
||||
.PHONY: local-worker
|
||||
local-worker: ## [local] 本機 go run Go job worker (:8891)
|
||||
cd $(BACKEND_DIR) && go run ./cmd/worker -f etc/gateway.worker.yaml
|
||||
|
||||
.PHONY: dev-node-worker
|
||||
dev-node-worker: ## [dev] 跑 Node playwright worker (style-8d)
|
||||
.PHONY: local-node-worker
|
||||
local-node-worker: ## [local] 本機跑 Node playwright worker (style-8d)
|
||||
cd $(BACKEND_DIR)/worker && npm install && \
|
||||
HAIXUN_WORKER_SECRET=$(DEV_WORKER_SECRET) HAIXUN_BACKEND_URL=$(DEV_BACKEND_URL) npm run style-8d
|
||||
|
||||
.PHONY: dev-frontend
|
||||
dev-frontend: ## [dev] 跑前端 dev server (:5173)
|
||||
.PHONY: local-frontend
|
||||
local-frontend: ## [local] 本機跑前端 dev server (:5173)
|
||||
cd $(FRONTEND_DIR) && npm install && npm run dev
|
||||
|
||||
.PHONY: dev-init
|
||||
dev-init: ## [dev] 初始化 DB(索引/權限)並建立 admin 帳號(可用 INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD 覆寫)
|
||||
.PHONY: local-init
|
||||
local-init: ## [local] 本機初始化 DB(索引/權限)並建立 admin
|
||||
cd $(BACKEND_DIR) && go run ./cmd/tool init -f etc/gateway.yaml
|
||||
|
||||
.PHONY: dev
|
||||
dev: ## [dev] 顯示本機開發要開的終端
|
||||
@echo "本機開發請分開幾個終端執行:"
|
||||
@echo " 1) make dev-infra # Mongo/Redis"
|
||||
@echo " 2) make dev-backend # gateway :8890"
|
||||
@echo " 3) make dev-worker # go worker(可選)"
|
||||
@echo " 4) make dev-node-worker # node worker(需 style-8d 時)"
|
||||
@echo " 5) make dev-frontend # 前端 :5173"
|
||||
|
||||
# ============================================================
|
||||
# 安裝依賴(新機器 clone 後執行一次)
|
||||
# ============================================================
|
||||
|
||||
.PHONY: bootstrap-sys
|
||||
bootstrap-sys: ## [需 sudo] 安裝系統依賴(Go, Node.js, Docker, Nginx)— Ubuntu 專用
|
||||
@echo "=== 安裝系統套件: nginx, curl, gnupg ==="
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq nginx curl gnupg ca-certificates
|
||||
@echo ""
|
||||
@echo "=== 安裝 Go 1.22+ ==="
|
||||
@GO_VER=$$(curl -sL https://go.dev/VERSION?m=text 2>/dev/null | head -1 || echo "go1.22"); \
|
||||
echo "下載 $$GO_VER.linux-amd64.tar.gz"; \
|
||||
curl -sL "https://go.dev/dl/$$GO_VER.linux-amd64.tar.gz" -o /tmp/go.tar.gz && \
|
||||
sudo rm -rf /usr/local/go && \
|
||||
sudo tar -C /usr/local -xzf /tmp/go.tar.gz && \
|
||||
rm /tmp/go.tar.gz; \
|
||||
echo 'export PATH=/usr/local/go/bin:$$PATH' | sudo tee /etc/profile.d/go.sh
|
||||
@echo ""
|
||||
@echo "=== 安裝 Node.js LTS ==="
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y -qq nodejs
|
||||
@echo ""
|
||||
@echo "=== 安裝 Docker Engine + Compose ==="
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker $(USER) 2>/dev/null || true
|
||||
@echo ""
|
||||
@echo "=== 安裝 Nginx ==="
|
||||
sudo apt-get install -y -qq nginx
|
||||
@echo ""
|
||||
@echo "✓ 系統依賴安裝完成。"
|
||||
@echo " 登出再登入後,Go/Node/Docker 即可使用(group 變更生效)。"
|
||||
@echo " 接著執行 make bootstrap 安裝專案層依賴。"
|
||||
@echo ""
|
||||
|
||||
.PHONY: bootstrap
|
||||
bootstrap: ## 安裝專案層依賴(Go modules + npm + Playwright),需先執行 make bootstrap-sys
|
||||
@echo "=== [1/4] Go modules ==="
|
||||
cd $(BACKEND_DIR) && go mod tidy
|
||||
@echo ""
|
||||
@echo "=== [2/4] 前端 npm ==="
|
||||
cd $(FRONTEND_DIR) && npm install
|
||||
@echo ""
|
||||
@echo "=== [3/4] Node worker npm ==="
|
||||
cd $(BACKEND_DIR)/worker && npm install
|
||||
@echo ""
|
||||
@echo "=== [4/4] Playwright 瀏覽器(chromium)+ 系統依賴 ==="
|
||||
cd $(BACKEND_DIR)/worker && sudo npx playwright install --with-deps chromium
|
||||
@echo ""
|
||||
@echo "✓ 全裝完成。後續步驟:"
|
||||
@echo " 1) make dev-infra # 起 Mongo/Redis (Docker)"
|
||||
@echo " 2) make dev-init # 初始化 DB + 建立 admin"
|
||||
@echo " 3) make dev-backend # 啟動 gateway (:8890)"
|
||||
@echo " 4) sudo make install # 正式部署(含 systemd + nginx)"
|
||||
|
||||
# ============================================================
|
||||
# BUILD (prod 產物)
|
||||
# ============================================================
|
||||
|
||||
.PHONY: build
|
||||
build: build-frontend build-backend ## [prod] 建置前端 dist 與 Go binary
|
||||
|
||||
.PHONY: build-frontend
|
||||
build-frontend: ## [prod] 前端靜態建置 (tsc + vite) -> frontend/dist
|
||||
cd $(FRONTEND_DIR) && npm ci && npm run build
|
||||
|
||||
.PHONY: build-backend
|
||||
build-backend: ## [prod] 交叉編譯 gateway + worker -> backend/bin (linux)
|
||||
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
|
||||
go build -trimpath -ldflags "-s -w" -o bin/gateway .
|
||||
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
|
||||
go build -trimpath -ldflags "-s -w" -o bin/worker ./cmd/worker
|
||||
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
|
||||
go build -trimpath -ldflags "-s -w" -o bin/tool ./cmd/tool
|
||||
@echo "binary 已輸出到 $(BIN_DIR)/(gateway / worker / tool)"
|
||||
|
||||
# ============================================================
|
||||
# PROD (部署)
|
||||
# ============================================================
|
||||
|
||||
.PHONY: prod-infra
|
||||
prod-infra: $(INFRA_DIR)/.env ## [prod] 起 Mongo + Redis (docker,背景)
|
||||
$(COMPOSE) up -d
|
||||
|
||||
.PHONY: prod-infra-down
|
||||
prod-infra-down: ## [prod] 停掉 Mongo + Redis
|
||||
$(COMPOSE) down
|
||||
|
||||
.PHONY: prod-init
|
||||
prod-init: ## [prod] 目標主機初始化 DB + 建立 admin(讀 /opt/haixun/etc/haixun.env;可加 INIT_ADMIN_EMAIL/PASSWORD)
|
||||
@test -x $(DEPLOY_ROOT)/bin/tool || (echo "缺少 $(DEPLOY_ROOT)/bin/tool,請先 make install" && exit 1)
|
||||
set -a; . $(DEPLOY_ROOT)/etc/haixun.env; set +a; \
|
||||
$(DEPLOY_ROOT)/bin/tool init -f $(DEPLOY_ROOT)/etc/gateway.prod.yaml
|
||||
|
||||
.PHONY: install
|
||||
install: ## [prod] 安裝 binary/前端/設定/systemd/nginx(需 root,在目標主機執行)
|
||||
@test -f $(BIN_DIR)/gateway || (echo "缺少 $(BIN_DIR)/gateway,請先在能 build 的機器執行 make build" && exit 1)
|
||||
@test -d $(FRONTEND_DIR)/dist || (echo "缺少 $(FRONTEND_DIR)/dist,請先 make build-frontend" && exit 1)
|
||||
id haixun >/dev/null 2>&1 || useradd --system --home $(DEPLOY_ROOT) --shell /usr/sbin/nologin haixun
|
||||
install -d $(DEPLOY_ROOT)/bin $(DEPLOY_ROOT)/etc $(DEPLOY_ROOT)/node-worker $(WEB_ROOT)
|
||||
install -m 0755 $(BIN_DIR)/gateway $(DEPLOY_ROOT)/bin/gateway
|
||||
install -m 0755 $(BIN_DIR)/worker $(DEPLOY_ROOT)/bin/worker
|
||||
install -m 0755 $(BIN_DIR)/tool $(DEPLOY_ROOT)/bin/tool
|
||||
install -m 0644 $(BACKEND_DIR)/etc/gateway.prod.yaml $(DEPLOY_ROOT)/etc/gateway.prod.yaml
|
||||
install -m 0644 $(BACKEND_DIR)/etc/gateway.worker.prod.yaml $(DEPLOY_ROOT)/etc/gateway.worker.prod.yaml
|
||||
rm -rf $(WEB_ROOT)/* && cp -r $(FRONTEND_DIR)/dist/* $(WEB_ROOT)/
|
||||
cp -r $(BACKEND_DIR)/worker/* $(DEPLOY_ROOT)/node-worker/
|
||||
cd $(DEPLOY_ROOT)/node-worker && npm ci && npx playwright install --with-deps chromium
|
||||
install -m 0644 $(INFRA_DIR)/systemd/haixun-gateway.service /etc/systemd/system/haixun-gateway.service
|
||||
install -m 0644 $(INFRA_DIR)/systemd/haixun-worker.service /etc/systemd/system/haixun-worker.service
|
||||
install -m 0644 $(INFRA_DIR)/systemd/haixun-node-worker.service /etc/systemd/system/haixun-node-worker.service
|
||||
install -m 0644 $(INFRA_DIR)/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf
|
||||
chown -R haixun:haixun $(DEPLOY_ROOT) $(WEB_ROOT)
|
||||
@echo "----"
|
||||
@echo "接著(只做一次)建立 secret 檔:"
|
||||
@echo " cp $(INFRA_DIR)/etc/haixun.env.example $(DEPLOY_ROOT)/etc/haixun.env && chmod 600 $(DEPLOY_ROOT)/etc/haixun.env && sudoedit $(DEPLOY_ROOT)/etc/haixun.env"
|
||||
@echo "再啟用服務:"
|
||||
@echo " systemctl daemon-reload && systemctl enable --now haixun-gateway haixun-worker haixun-node-worker"
|
||||
@echo " nginx -t && systemctl reload nginx"
|
||||
|
||||
# ============================================================
|
||||
# 驗證 / 維護
|
||||
# ============================================================
|
||||
|
|
@ -214,6 +181,10 @@ install: ## [prod] 安裝 binary/前端/設定/systemd/nginx(需 root,在目
|
|||
tidy: ## go mod tidy
|
||||
cd $(BACKEND_DIR) && go mod tidy
|
||||
|
||||
.PHONY: gen-api
|
||||
gen-api: ## 用 goctl 從 .api 重新產生 handler/logic/types/routes
|
||||
cd $(BACKEND_DIR) && goctl api go -api generate/api/gateway.api -dir . -home generate/goctl -style go_zero
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ## gofmt 後端
|
||||
cd $(BACKEND_DIR) && gofmt -w .
|
||||
|
|
@ -223,7 +194,7 @@ test: ## 跑後端測試
|
|||
cd $(BACKEND_DIR) && go test ./...
|
||||
|
||||
.PHONY: verify
|
||||
verify: ## 後端 build/test + 前端 build + compose 語法
|
||||
verify: $(ENV_FILE) ## 後端 build/test + 前端 build + compose 語法
|
||||
cd $(BACKEND_DIR) && go build ./... && go test ./...
|
||||
cd $(FRONTEND_DIR) && npm ci && npm run build
|
||||
$(COMPOSE) config >/dev/null && echo "docker compose config OK"
|
||||
|
|
|
|||
|
|
@ -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 加「來源」badge(scan+web/sky/success、web/sky、scan/brand)、「置信度」小字。
|
||||
- **schema**:`missionentity.SimilarAccount` 加 `MatchedSource []string`(`backend/internal/model/copy_mission/domain/entity/mission.go:27`);`domusecase.SimilarAccountSummary` 同步;`usecase.toSummary` 帶 matched;`types.CopySimilarAccountData` 同步;`logic/copy_mission/mapper.go` entity↔API 兩處對映帶 matched;`generate/api/copy_mission.api` 的 `CopySimilarAccountData` 同步(手動補,**未跑 `make gen-api`**——必要時下一手可跑 gen-api 重新產生以對齊)。
|
||||
- 新檔:
|
||||
- `backend/internal/library/viral/merge_accounts.go` `MergeSimilarAccounts` + `unionMatchedSource`。
|
||||
- `backend/internal/library/threadsapi/url.go` `ProfileURLFromPermalink`。
|
||||
- `backend/internal/worker/job/similar_accounts.go` `similarAccountsFromSummary`(summary→entity 轉換 helper,給 worker 跨 rerun 保留 Previous)。
|
||||
|
||||
### 未做、給下一手
|
||||
- **Phase 2**:相似帳號變一等公民(`Status`/`TopicRelevance`/`LastSeenAt` schema + PATCH API + worker 尊重 excluded/pinned + 前端釘選/排除按鈕 + Placement 維持不動)。詳見下方 Phase 2 章節。
|
||||
- **Phase 3**:TA 樣本(留言作者採樣,`AudienceSample` + `audience.go` + worker 接入 + PATCH API + 前端「TA 樣本」區)。詳見下方 Phase 3 章節。
|
||||
- **Phase 4**:跨任務記憶(後續,未規劃細節)。
|
||||
- **重新 gen-api**:`generate/api/copy_mission.api` 只有 `CopySimilarAccountData` 加 `matched_source` 一處,types.go 已手動同步。若下一手要擴 API schema(Phase 2),建議跑 `make gen-api` 重新產 handler/logic/types 確保對齊。
|
||||
- **新測試**:Phase 1 沒額外加新 test case。建議下一手補:`viral/merge_accounts_test.go`(`MergeSimilarAccounts` merges、preserves prev、truncates to limit、unionMatchedSource dedup)、`viral/reference_accounts_test.go` 新排序 case(topicHits 加權、log-scale follower bucket)、`worker/job/scan_viral_test.go` web fallback case(mock websearch client)。這些 test 是 Phase 1 的「補強」層,不阻塞 Phase 2 進行。
|
||||
|
||||
### 已動的檔案清單(Phase 1)
|
||||
**Go 後端**
|
||||
- `backend/internal/library/viral/discover_accounts.go`(常數、ProfileURL helper、accountCandidate.permalink)
|
||||
- `backend/internal/library/viral/reference_accounts.go`(ReferenceRankWeights、topicHits、MatchedSource、ProfileURL helper、刪 topicTerms)
|
||||
- `backend/internal/library/viral/enrich_accounts.go`(MatchedSource、ProfileURL helper、authorScore.permalink)
|
||||
- `backend/internal/library/viral/merge_accounts.go`(**新檔**)
|
||||
- `backend/internal/library/threadsapi/url.go`(**新檔**)
|
||||
- `backend/internal/library/websearch/client.go`(braveAdapter 傳 StartPublishedDate)
|
||||
- `backend/internal/library/brave/client.go`(SearchOptions 加 StartPublishedDate)
|
||||
- `backend/internal/worker/job/scan_viral.go`(web fallback + MergeSimilarAccounts + supplementSimilarAccountsFromWeb)
|
||||
- `backend/internal/worker/job/analyze_copy_mission.go`(停止清空 SimilarAccounts)
|
||||
- `backend/internal/worker/job/similar_accounts.go`(**新檔**:similarAccountsFromSummary)
|
||||
- `backend/internal/model/copy_mission/domain/entity/mission.go`(SimilarAccount 加 MatchedSource)
|
||||
- `backend/internal/model/copy_mission/domain/usecase/usecase.go`(SimilarAccountSummary 加 MatchedSource)
|
||||
- `backend/internal/model/copy_mission/usecase/usecase.go`(toSummary 帶 matched)
|
||||
- `backend/internal/types/types.go`(CopySimilarAccountData 加 matched_source)
|
||||
- `backend/generate/api/copy_mission.api`(CopySimilarAccountData 加 matched_source)
|
||||
- `backend/internal/logic/copy_mission/mapper.go`(兩處對映帶 matched)
|
||||
|
||||
**前端**
|
||||
- `frontend/src/types/copyMission.ts`(CopySimilarAccountData 加 matched_source + helper)
|
||||
- `frontend/src/lib/viralSignals.ts`(similarAccountTone)
|
||||
- `frontend/src/pages/CopyMissionDetailPage.tsx`(row 顯示 source badge、置信度小字)
|
||||
|
||||
### 設計選擇備忘
|
||||
- `similarAccountsFromSummary` 只帶 summary 可見欄位(沒 `TopicRelevance/LastSeenAt/Status`,那些是 Phase 2)。Phase 1 schema 只補 `MatchedSource`,其他 Phase 2 欄位別在 Phase 1 先加,避免污染。
|
||||
- 排序權重改成 log-scale follower bucket(1k/10k/100k/1M cut),避免 mega 帳號壓過 niche 高相關作者。`ReferenceRankWeights` 結構在 `reference_accounts.go` 內部,未注入 input,未來要調整再加。
|
||||
- `ProfileURL` 從 `https://www.threads.net/@` 改 `https://www.threads.com/@`(threads.com 現在是 canonical host,前端 `frontend/src/lib/threadsLinks.ts` 用 `threadsProfileUrl(username, profileUrl)` 已能接受 `profile_url` injected,不需改)。
|
||||
- `MergeSimilarAccounts` 串接 priority:新(scan+web)> 舊(保舊不被清掉),命中兩條 source 的 username `MatchedSource` 會 union 含 scan 與 web,但 **confidence 不會自動升 high**(confidence 由 `BuildReferenceAccountsFromScan` 內 strict/relaxed gate + verified + bestEngagement 算出來)。若未來要把「跨來源」當 high confidence 的訊號,需在 `MergeSimilarAccounts` 或 mapper 後處理;目前只透過前端 `MatchedSource` badge 視覺呈現。
|
||||
- web search fallback 只在 `< MaxSimilarAccounts(=10)` 時觸發,避免每次 scan 都額外打 web 浪費 quota;也避免 web 來的帳號擠掉 scan 來的(因為 new 排前面)。
|
||||
- Brave `StartPublishedDate` 欄位加了但**沒 map 到 Brave 的 `freshness` 參數**(Brave API freshness 接受 `d/w/m/y` 而非 ISO date,要做轉換)。Phase 1 只做資訊不丟失;實際生效需要第二階段處理 Brave adapter 的 `freshness` mapping。Exa 已有支援。
|
||||
|
||||
### 沒動的檔案(確認 boundary)
|
||||
- Placement / Persona / Brand schema 完全沒動。
|
||||
- 沒碰 `internal/handler/routes.go`、`internal/svc/service_context.go`、`internal/response`、`internal/middleware`。
|
||||
- 沒動 `internal/model/copy_mission/repository`、`internal/model/copy_mission/domain/repository`。
|
||||
|
||||
---
|
||||
|
||||
> 範圍:僅 CopyMission flow。Placement(B 流海巡獲客)刻意維持「無對標帳號」設計,本計畫不動其 schema。
|
||||
> 上限:`MaxSimilarAccounts` 自 5 放寬到 10。
|
||||
> 遵循:`code/message/data` envelope、`page/pageSize`、`SSCCCDDD` 錯誤碼、UTC+0 nanoseconds、Redis `workerID` lock、不裸 `Update` job 狀態。
|
||||
> 驗證:`go mod tidy && make fmt && go test ./...`;前端動到另執行 `make web-build`。
|
||||
|
||||
---
|
||||
|
||||
## 背景:threads-api-skill repo 對位
|
||||
|
||||
參考 repo `madebypan/threads-api-skill` 是**純發布工具包**(容器→publish 30s wait、reply_to_id 串貼、catbox.moe 圖床、60 天 token refresh、Chrome 自動化 setup),**沒有「找相似帳號 / 找對標 / 找 TA」功能**。它整理的 Threads Graph API endpoint 與踩坑,可用來校正下列事實:
|
||||
|
||||
- `me?fields=id,name,username,...` 取 self 資訊 → 目前 `threadsapi/client.go` 完全沒用,無法驗 token 對應 username。
|
||||
- `keyword_search`(TOP/RECENT,50/page,無 cursor)→ 已實作;目前沒做翻頁,召回量受限。
|
||||
- `media/{id}/replies` → 已實作,目前只當 outreach 標的,**沒把留言作者彙整成 TA 樣本**。
|
||||
- Threads Graph API **沒有 endpoint 查任意別人的 follower / 受眾輪廓** → 「找 TA」必須繞道:把留言作者/互動者彙整成潛在受眾樣本。
|
||||
|
||||
---
|
||||
|
||||
## 目前狀態(viral 流程摸整)
|
||||
|
||||
- 唯一活著的相似帳號產生路徑:`scan_viral.go` job → `libviral.RunDiscover`(抓 threads 貼文)→ `BuildReferenceAccountsFromScan`(by author 聚合 + verified/follower/engagement 排序,strict gate→relaxed fallback)→ 寫入 `Mission.ResearchMap.SimilarAccounts`(`reference_accounts.go:43`)。
|
||||
- `DiscoverSimilarAccounts` / `EnrichSimilarAccounts` / `ToEntitySimilarAccounts` 是 dead code(只有測試呼叫)。
|
||||
- `analyze_copy_mission.go:138` 顯式 `researchMap.SimilarAccounts = nil` → 重新跑研究地圖 job 會清空相似帳號,前端沒警告。
|
||||
- API 對相似帳號純唯讀:`UpdateCopyMissionReq`(`copy_mission.api:60-72`)沒 `similar_accounts` 欄位,使用者無法排除/釘選。
|
||||
- 「找 TA」目前只是 LLM 產出的一段文字 `AudienceSummary`,**沒有任何「找出受眾樣本帳號」的機制**,連結構都沒有。
|
||||
- 驗證缺位:`ProfileURL` 一律 `https://www.threads.net/@<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 — 相似帳號變一等公民(動 API,CopyMission only)
|
||||
|
||||
### 2.1 schema 變更
|
||||
`backend/internal/model/copy_mission/domain/entity/mission.go:23-35` `SimilarAccount` 加:
|
||||
```go
|
||||
Status string `bson:"status,omitempty" json:"status,omitempty"` // recommended|pinned|excluded|promoted
|
||||
TopicRelevance float64 `bson:"topic_relevance,omitempty" json:"topic_relevance,omitempty"`
|
||||
LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
|
||||
MatchedSource []string `bson:"matched_source,omitempty" json:"matched_source,omitempty"`
|
||||
```
|
||||
`internal/types/types.go` 同步(重新 gen-api)。
|
||||
|
||||
### 2.2 API 新增
|
||||
`backend/generate/api/copy_mission.api` 加:
|
||||
```
|
||||
@handler patchCopyMissionSimilarAccount
|
||||
patch /personas/:persona_id/copy-missions/:copy_mission_id/similar-accounts/:username (PatchSimilarAccountReq) returns (CommonRes)
|
||||
|
||||
type PatchSimilarAccountReq {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
```
|
||||
跑 `make gen-api` 重新產 handler/logic/types。logic 走 dot-path:
|
||||
```go
|
||||
out[fmt.Sprintf("research_map.similar_accounts.%s.status", username)] = strings.TrimSpace(req.Status)
|
||||
```
|
||||
錯誤碼:CopyMission 目前沒有專屬 scope,**Phase 2 用 `Facade` (10)** 暫時頂著,待新增 `CopyMission Scope=41` 後再回頭改。**禁止裸寫數字**,一律 `errs.For(code.Facade).ResInvalidState("similar account status not allowed")`。
|
||||
|
||||
### 2.3 worker 尊重 status
|
||||
- `reference_accounts.go:54` 開頭:`if isExcluded(in.ExcludedUsernames, post.Author) { continue }`。
|
||||
- `scan_viral.go:170`:把 `mission.ResearchMap.SimilarAccounts` 中 `status=excluded` 的 username 傳進 `ReferenceAccountInput.ExcludedUsernames`。
|
||||
- `merge_accounts.go`:`status=pinned` 永遠置頂,本次沒掃到也保留。
|
||||
|
||||
### 2.4 前端互動
|
||||
- `CopyMissionDetailPage.tsx` row 加兩按鈕「釘選」「排除」,呼 `api.patch(.../similar-accounts/:username, {status})`,本地 optimistic update。
|
||||
- 新檔 `frontend/src/lib/copyMissionSimilarAccounts.ts` 包 API client。
|
||||
- `CopyMissionResearchOverview.tsx` 加「相似帳號」子區(從 detail 頁整合進來)。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — TA 樣本(留言作者採樣,全新功能)
|
||||
|
||||
Threads API 拿不到任意別人的受眾 → 繞道:用 `MediaReplies` 的 `ReplyCandidate.Author` 聚合成潛在受眾樣本。
|
||||
|
||||
### 3.1 schema 變更
|
||||
`backend/internal/model/copy_mission/domain/entity/mission.go` `ResearchMap` 加:
|
||||
```go
|
||||
AudienceSamples []AudienceSample `bson:"audience_samples,omitempty" json:"audience_samples,omitempty"`
|
||||
```
|
||||
新 struct:
|
||||
```go
|
||||
type AudienceSample struct {
|
||||
Username string `bson:"username" json:"username"`
|
||||
SamplePostID string `bson:"sample_post_id,omitempty" json:"sample_post_id,omitempty"`
|
||||
SampleText string `bson:"sample_text,omitempty" json:"sample_text,omitempty"`
|
||||
ReplyLikeCount int `bson:"reply_like_count,omitempty" json:"reply_like_count,omitempty"`
|
||||
Appearances int `bson:"appearances,omitempty" json:"appearances,omitempty"`
|
||||
FirstSeenAt int64 `bson:"first_seen_at" json:"first_seen_at"`
|
||||
LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
|
||||
Status string `bson:"status,omitempty" json:"status,omitempty"`
|
||||
}
|
||||
```
|
||||
`generate/api/copy_mission.api` 在 `CopyResearchMap` 加 `AudienceSamples []CopyAudienceSampleData`;`make gen-api`。
|
||||
|
||||
### 3.2 新 library
|
||||
`backend/internal/library/viral/audience.go`:
|
||||
```go
|
||||
func BuildAudienceSamplesFromReplies(replies []placement.ReplyCandidate, opts AudienceOpts) []missionentity.AudienceSample
|
||||
```
|
||||
- `appearance = by username.Count`
|
||||
- 過濾:自己 mission 的 author(即 SimilarAccount source)排除、純連結/數字罐頭 reply 文字過濾(`len < 5 chars` or URL-only)。
|
||||
- 排序 `appearance desc → replyLikeCount desc → postedAt desc`,前 15 筆。
|
||||
- confidence(前端呈現用,不入 struct):`appearance >= 3` high、`= 2` medium、`= 1` low。
|
||||
|
||||
### 3.3 worker 接入
|
||||
`scan_viral.go` 在 `AttachReplies` 完成後:
|
||||
```go
|
||||
var allReplies []placement.ReplyCandidate
|
||||
for _, p := range postsWithReplies {
|
||||
allReplies = append(allReplies, p.Replies...)
|
||||
}
|
||||
samples := libviral.BuildAudienceSamplesFromReplies(allReplies, libviral.AudienceOpts{
|
||||
Max: 15,
|
||||
ExcludeAuthors: similarAccountUsernames,
|
||||
})
|
||||
researchMap.AudienceSamples = mergeAudienceSamples(prev.Samples, samples)
|
||||
```
|
||||
合併邏輯與 `merge_accounts.go` 同款。
|
||||
|
||||
### 3.4 API
|
||||
- `GET /copy-missions/:id` 已含(在 `research_map.audience_samples`)。
|
||||
- `PATCH /copy-missions/:id/audience-samples/:username` body `{status}`,dot-path 更新 `research_map.audience_samples.<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 時直接跳過整段,不報錯。
|
||||
|
|
@ -3,13 +3,17 @@ Host: 0.0.0.0
|
|||
Port: 8890
|
||||
Timeout: 120000
|
||||
|
||||
# 本地「docker 測試」設定(make dev-up 使用)。
|
||||
# 連線走 compose service name(mongo / redis),密碼對齊 deploy/.env.dev。
|
||||
# 純本機 go run(不進 docker)請改用 etc/gateway.yaml(連 127.0.0.1)。
|
||||
Mongo:
|
||||
URI: mongodb://127.0.0.1:27017
|
||||
Database: haixun_dev
|
||||
URI: mongodb://haixun:haixun-dev-pass@mongo:27017/?authSource=admin
|
||||
Database: haixun
|
||||
TimeoutSeconds: 10
|
||||
|
||||
Redis:
|
||||
Addr: 127.0.0.1:6379
|
||||
Addr: redis:6379
|
||||
Password: haixun-dev-redis
|
||||
DB: 0
|
||||
|
||||
Auth:
|
||||
|
|
@ -17,4 +21,25 @@ Auth:
|
|||
RefreshSecret: haixun-dev-refresh-secret-change-me
|
||||
AccessExpireSeconds: 900
|
||||
RefreshExpireSeconds: 2592000
|
||||
# 僅本地開發開啟:允許用 X-Tenant-ID / X-UID header 模擬登入。正式環境必須 false。
|
||||
DevHeaderFallback: true
|
||||
|
||||
Secrets:
|
||||
# 留空 = 機敏資料不加密(本地測試方便)。正式環境用 ${HAIXUN_SECRETS_KEY}。
|
||||
EncryptionKey: ""
|
||||
|
||||
InternalWorker:
|
||||
Secret: haixun-dev-worker-secret
|
||||
|
||||
# dev 用 gateway 內嵌 go worker(勿另開 worker 容器,避免兩個 worker 搶 job)。
|
||||
JobWorker:
|
||||
Enabled: true
|
||||
WorkerType: go
|
||||
|
||||
JobScheduler:
|
||||
Enabled: true
|
||||
IntervalSeconds: 60
|
||||
|
||||
JobReaper:
|
||||
Enabled: true
|
||||
IntervalSeconds: 30
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ type (
|
|||
Username string `json:"username"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
MatchedSource []string `json:"matched_source,omitempty"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
TopicRelevance float64 `json:"topic_relevance,omitempty"`
|
||||
LastSeenAt int64 `json:"last_seen_at,omitempty"`
|
||||
ProfileUrl string `json:"profile_url,omitempty"`
|
||||
AuthorVerified bool `json:"author_verified,omitempty"`
|
||||
FollowerCount int `json:"follower_count,omitempty"`
|
||||
|
|
@ -22,17 +26,73 @@ type (
|
|||
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 {
|
||||
AudienceSummary string `json:"audience_summary,omitempty"`
|
||||
ContentGoal string `json:"content_goal,omitempty"`
|
||||
Questions []string `json:"questions,omitempty"`
|
||||
Pillars []string `json:"pillars,omitempty"`
|
||||
Exclusions []string `json:"exclusions,omitempty"`
|
||||
KnowledgeItems []string `json:"knowledge_items,omitempty"`
|
||||
SelectedKnowledgeItems []string `json:"selected_knowledge_items,omitempty"`
|
||||
ResearchItems []CopyMissionResearchItemData `json:"research_items,omitempty"`
|
||||
SuggestedTags []CopySuggestedTagData `json:"suggested_tags,omitempty"`
|
||||
SimilarAccounts []CopySimilarAccountData `json:"similar_accounts,omitempty"`
|
||||
AudienceSamples []CopyAudienceSampleData `json:"audience_samples,omitempty"`
|
||||
BenchmarkNotes string `json:"benchmark_notes,omitempty"`
|
||||
}
|
||||
|
||||
ExpandCopyMissionGraphHandlerReq {
|
||||
CopyMissionPath
|
||||
ExpandKnowledgeGraphReq
|
||||
}
|
||||
|
||||
PatchCopyMissionGraphNodesHandlerReq {
|
||||
CopyMissionPath
|
||||
PatchKnowledgeGraphNodesReq
|
||||
}
|
||||
|
||||
PatchSimilarAccountReq {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
PatchAudienceSampleReq {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
CopyMissionSimilarAccountPath {
|
||||
PersonaID string `path:"personaId" validate:"required"`
|
||||
ID string `path:"id" validate:"required"`
|
||||
Username string `path:"username" validate:"required"`
|
||||
}
|
||||
|
||||
PatchSimilarAccountHandlerReq {
|
||||
CopyMissionSimilarAccountPath
|
||||
PatchSimilarAccountReq
|
||||
}
|
||||
|
||||
PatchAudienceSampleHandlerReq {
|
||||
CopyMissionSimilarAccountPath
|
||||
PatchAudienceSampleReq
|
||||
}
|
||||
|
||||
CopyMissionData {
|
||||
ID string `json:"id"`
|
||||
PersonaID string `json:"persona_id"`
|
||||
|
|
@ -66,6 +126,8 @@ type (
|
|||
Questions []string `json:"questions,optional"`
|
||||
Pillars []string `json:"pillars,optional"`
|
||||
Exclusions []string `json:"exclusions,optional"`
|
||||
KnowledgeItems []string `json:"knowledge_items,optional"`
|
||||
SelectedKnowledgeItems []string `json:"selected_knowledge_items,optional"`
|
||||
BenchmarkNotes *string `json:"benchmark_notes,optional"`
|
||||
SelectedTags []string `json:"selected_tags,optional"`
|
||||
Status *string `json:"status,optional"`
|
||||
|
|
@ -102,6 +164,15 @@ type (
|
|||
PersonaID string `path:"personaId" validate:"required"`
|
||||
}
|
||||
|
||||
CopyMissionInspirationReq {
|
||||
Keyword string `json:"keyword,optional"`
|
||||
}
|
||||
|
||||
CopyMissionInspirationHandlerReq {
|
||||
PersonaCopyMissionsPath
|
||||
CopyMissionInspirationReq
|
||||
}
|
||||
|
||||
CreateCopyMissionHandlerReq {
|
||||
PersonaCopyMissionsPath
|
||||
CreateCopyMissionReq
|
||||
|
|
@ -182,6 +253,20 @@ type (
|
|||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
DeleteCopyMissionMatrixDraftsReq {
|
||||
DraftIDs []string `json:"draft_ids,optional"`
|
||||
}
|
||||
|
||||
DeleteCopyMissionMatrixDraftsHandlerReq {
|
||||
CopyMissionPath
|
||||
DeleteCopyMissionMatrixDraftsReq
|
||||
}
|
||||
|
||||
DeleteCopyMissionMatrixDraftsData {
|
||||
Deleted int `json:"deleted"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
CopyMissionInspirationSourceData {
|
||||
Query string `json:"query,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
|
|
@ -213,7 +298,7 @@ service gateway {
|
|||
get /:personaId/copy-missions (PersonaCopyMissionsPath) returns (ListCopyMissionsData)
|
||||
|
||||
@handler inspireCopyMission
|
||||
post /:personaId/copy-mission-inspiration (PersonaCopyMissionsPath) returns (CopyMissionInspirationData)
|
||||
post /:personaId/copy-mission-inspiration (CopyMissionInspirationHandlerReq) returns (CopyMissionInspirationData)
|
||||
|
||||
@handler createCopyMission
|
||||
post /:personaId/copy-missions (CreateCopyMissionHandlerReq) returns (CopyMissionData)
|
||||
|
|
@ -248,9 +333,27 @@ service gateway {
|
|||
@handler listCopyMissionCopyDrafts
|
||||
get /:personaId/copy-missions/:id/copy-drafts (CopyMissionPath) returns (ListCopyMissionCopyDraftsData)
|
||||
|
||||
@handler deleteCopyMissionMatrixDrafts
|
||||
post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData)
|
||||
|
||||
@handler getCopyMissionScanSchedule
|
||||
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)
|
||||
|
||||
@handler upsertCopyMissionScanSchedule
|
||||
put /:personaId/copy-missions/:id/scan-schedule (UpsertCopyMissionScanScheduleHandlerReq) returns (CopyMissionScanScheduleData)
|
||||
|
||||
@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)
|
||||
}
|
||||
|
|
@ -50,6 +50,16 @@ type (
|
|||
ExaUserLocation *string `json:"exa_user_location,optional"`
|
||||
ExpandStrategy *string `json:"expand_strategy,optional"`
|
||||
}
|
||||
|
||||
MemberCapabilitiesData {
|
||||
DiscoverReady bool `json:"discover_ready"`
|
||||
AiReady bool `json:"ai_ready"`
|
||||
PublishReady bool `json:"publish_ready"`
|
||||
DiscoverHint string `json:"discover_hint,omitempty"`
|
||||
AiHint string `json:"ai_hint,omitempty"`
|
||||
PublishHint string `json:"publish_hint,omitempty"`
|
||||
ActiveThreadsAccountId string `json:"active_threads_account_id,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
@server(
|
||||
|
|
@ -71,4 +81,7 @@ service gateway {
|
|||
|
||||
@handler updateMemberPlacementSettings
|
||||
patch /me/placement-settings (UpdateMemberPlacementSettingsReq) returns (MemberPlacementSettingsData)
|
||||
|
||||
@handler getMemberCapabilities
|
||||
get /me/capabilities returns (MemberCapabilitiesData)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,6 +183,11 @@ type (
|
|||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
DeleteCopyDraftData {
|
||||
DraftID string `json:"draft_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
)
|
||||
|
||||
@server(
|
||||
|
|
@ -228,4 +233,7 @@ service gateway {
|
|||
|
||||
@handler publishPersonaCopyDraft
|
||||
post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData)
|
||||
|
||||
@handler deletePersonaCopyDraft
|
||||
delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
module haixun-backend
|
||||
|
||||
go 1.22
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
|
|
@ -14,6 +14,8 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
|
|
@ -24,6 +26,9 @@ require (
|
|||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/grafana/pyroscope-go v1.2.7 // indirect
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
|
|
@ -30,6 +34,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 h1:A3B75Yp163FAIf9nLlFMl4pwIj+T3uKxfI7mbvvY2Ls=
|
||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0/go.mod h1:suxK0Wpz4BM3/2+z1mnOVTIWHDiMCIOGoKDCRumSsk0=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
|
|
@ -61,6 +71,7 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
|||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
|
|
@ -83,10 +94,12 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
|
|||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
|
||||
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
|
@ -94,6 +107,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
|||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
|
|
@ -141,16 +155,35 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
|
@ -159,20 +192,42 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.PersonaCopyMissionsPath
|
||||
var req types.CopyMissionInspirationHandlerReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -263,6 +263,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:personaId/copy-missions/:id/analyze-jobs",
|
||||
Handler: copy_mission.StartCopyMissionAnalyzeJobHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Path: "/:personaId/copy-missions/:id/audience-samples/:username",
|
||||
Handler: copy_mission.PatchCopyMissionAudienceSampleHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:personaId/copy-missions/:id/copy-draft-jobs",
|
||||
|
|
@ -273,11 +278,31 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:personaId/copy-missions/:id/copy-drafts",
|
||||
Handler: copy_mission.ListCopyMissionCopyDraftsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:personaId/copy-missions/:id/knowledge-graph",
|
||||
Handler: copy_mission.GetCopyMissionGraphHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:personaId/copy-missions/:id/knowledge-graph/expand",
|
||||
Handler: copy_mission.ExpandCopyMissionGraphHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Path: "/:personaId/copy-missions/:id/knowledge-graph/nodes",
|
||||
Handler: copy_mission.PatchCopyMissionGraphNodesHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:personaId/copy-missions/:id/matrix-drafts",
|
||||
Handler: copy_mission.GenerateCopyMissionMatrixHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:personaId/copy-missions/:id/matrix-drafts/delete",
|
||||
Handler: copy_mission.DeleteCopyMissionMatrixDraftsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:personaId/copy-missions/:id/matrix-jobs",
|
||||
|
|
@ -303,6 +328,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:personaId/copy-missions/:id/scan-schedule",
|
||||
Handler: copy_mission.UpsertCopyMissionScanScheduleHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Path: "/:personaId/copy-missions/:id/similar-accounts/:username",
|
||||
Handler: copy_mission.PatchCopyMissionSimilarAccountHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithPrefix("/api/v1/personas"),
|
||||
|
|
@ -465,6 +495,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/me",
|
||||
Handler: member.UpdateMemberMeHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/me/capabilities",
|
||||
Handler: member.GetMemberCapabilitiesHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/me/placement-settings",
|
||||
|
|
@ -549,6 +584,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/:id/copy-drafts/:draftId",
|
||||
Handler: persona.UpdatePersonaCopyDraftHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodDelete,
|
||||
Path: "/:id/copy-drafts/:draftId",
|
||||
Handler: persona.DeletePersonaCopyDraftHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/copy-drafts/:draftId/publish",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ type SearchOptions struct {
|
|||
Mode Mode
|
||||
Country string
|
||||
SearchLang string
|
||||
// StartPublishedDate is accepted for forward-compatibility (Brave's
|
||||
// `freshness` query parameter maps from this once wired). Current brave
|
||||
// client keeps it as a passthrough hint and does not yet append it to the
|
||||
// request, so callers that depend on date filtering should pair it with
|
||||
// Exa (which honours startPublishedDate server-side).
|
||||
StartPublishedDate string
|
||||
}
|
||||
|
||||
func (c *Client) Search(ctx context.Context, opts SearchOptions) (SearchResponse, error) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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, "/"))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"haixun-backend/internal/library/websearch"
|
||||
)
|
||||
|
|
@ -22,6 +21,8 @@ type BraveCollectConfig struct {
|
|||
Concurrency int
|
||||
}
|
||||
|
||||
const minUsefulSourceRunes = 24
|
||||
|
||||
func BraveCollectConfigFromQueryCfg(cfg queryConfig) BraveCollectConfig {
|
||||
out := BraveCollectConfig{
|
||||
ResultsPerQuery: cfg.ResultsPerQuery,
|
||||
|
|
@ -99,7 +100,7 @@ func collectWebSourcesSequential(
|
|||
seenURL := map[string]struct{}{}
|
||||
|
||||
for i, query := range queries {
|
||||
if shouldStopCollect(out, cfg) {
|
||||
if shouldStopCollect(out, queries, cfg) {
|
||||
break
|
||||
}
|
||||
if heartbeat != nil {
|
||||
|
|
@ -107,7 +108,7 @@ func collectWebSourcesSequential(
|
|||
return out
|
||||
}
|
||||
}
|
||||
appendBraveResults(&out, seenURL, query, searchWebQuery(ctx, client, locale, query, cfg.ResultsPerQuery))
|
||||
appendBraveResults(&out, seenURL, query, searchWebQuery(ctx, client, locale, query, cfg.ResultsPerQuery), cfg.MaxSourcesCap)
|
||||
if onProgress != nil {
|
||||
onProgress(i, len(queries))
|
||||
}
|
||||
|
|
@ -121,34 +122,39 @@ type braveCollectState struct {
|
|||
out []BraveSource
|
||||
seenURL map[string]struct{}
|
||||
stop bool
|
||||
completed int32
|
||||
}
|
||||
|
||||
func (s *braveCollectState) shouldStop(cfg BraveCollectConfig) bool {
|
||||
func (s *braveCollectState) shouldStop(queries []string, cfg BraveCollectConfig) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.stop {
|
||||
return true
|
||||
}
|
||||
if shouldStopCollect(s.out, cfg) {
|
||||
if shouldStopCollect(s.out, queries, cfg) {
|
||||
s.stop = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *braveCollectState) appendResults(query string, items []BraveSource) {
|
||||
func (s *braveCollectState) appendResults(query string, items []BraveSource, queries []string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.stop {
|
||||
return
|
||||
}
|
||||
appendBraveResults(&s.out, s.seenURL, query, items)
|
||||
if shouldStopCollect(s.out, s.cfg) {
|
||||
appendBraveResults(&s.out, s.seenURL, query, items, s.cfg.MaxSourcesCap)
|
||||
if shouldStopCollect(s.out, queries, s.cfg) {
|
||||
s.stop = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *braveCollectState) snapshot() []BraveSource {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]BraveSource(nil), s.out...)
|
||||
}
|
||||
|
||||
func collectWebSourcesParallel(
|
||||
ctx context.Context,
|
||||
client websearch.Client,
|
||||
|
|
@ -172,45 +178,122 @@ func collectWebSourcesParallel(
|
|||
workers = 1
|
||||
}
|
||||
|
||||
jobs := make(chan int, len(queries))
|
||||
for i := range queries {
|
||||
jobs <- i
|
||||
completed := 0
|
||||
for next := 0; next < len(queries); {
|
||||
if state.shouldStop(queries, cfg) {
|
||||
break
|
||||
}
|
||||
batchEnd := next + workers
|
||||
if batchEnd > len(queries) {
|
||||
batchEnd = len(queries)
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range jobs {
|
||||
if state.shouldStop(cfg) {
|
||||
return
|
||||
type queryResult struct {
|
||||
index int
|
||||
items []BraveSource
|
||||
}
|
||||
results := make(chan queryResult, batchEnd-next)
|
||||
var wg sync.WaitGroup
|
||||
for i := next; i < batchEnd; i++ {
|
||||
if heartbeat != nil {
|
||||
if err := heartbeat(); err != nil {
|
||||
return
|
||||
return state.snapshot()
|
||||
}
|
||||
}
|
||||
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))
|
||||
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()
|
||||
return state.out
|
||||
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
|
||||
}
|
||||
return state.snapshot()
|
||||
}
|
||||
|
||||
func shouldStopCollect(out []BraveSource, cfg BraveCollectConfig) bool {
|
||||
func shouldStopCollect(out []BraveSource, queries []string, cfg BraveCollectConfig) bool {
|
||||
if len(out) >= cfg.MaxSourcesCap {
|
||||
return true
|
||||
}
|
||||
return len(out) >= cfg.MinSourcesBeforeStop && uniqueSourceCount(out) >= cfg.MinSourcesBeforeStop
|
||||
return sourcesAreSufficient(out, queries, cfg)
|
||||
}
|
||||
|
||||
func sourcesAreSufficient(out []BraveSource, queries []string, cfg BraveCollectConfig) bool {
|
||||
if cfg.MinSourcesBeforeStop <= 0 {
|
||||
return len(out) > 0
|
||||
}
|
||||
stats := sourceStats(out)
|
||||
if stats.UniqueURLs < cfg.MinSourcesBeforeStop {
|
||||
return false
|
||||
}
|
||||
minQueryCoverage := 1
|
||||
if len(queries) > 1 {
|
||||
minQueryCoverage = 2
|
||||
}
|
||||
if stats.QueryCoverage < minQueryCoverage {
|
||||
return false
|
||||
}
|
||||
minUseful := (cfg.MinSourcesBeforeStop * 2) / 3
|
||||
if minUseful < 1 {
|
||||
minUseful = 1
|
||||
}
|
||||
if stats.UsefulSources < minUseful {
|
||||
return false
|
||||
}
|
||||
return stats.TextRunes >= cfg.MinSourcesBeforeStop*minUsefulSourceRunes
|
||||
}
|
||||
|
||||
type collectSourceStats struct {
|
||||
UniqueURLs int
|
||||
QueryCoverage int
|
||||
UsefulSources int
|
||||
TextRunes int
|
||||
}
|
||||
|
||||
func sourceStats(items []BraveSource) collectSourceStats {
|
||||
seenURL := map[string]struct{}{}
|
||||
seenQuery := map[string]struct{}{}
|
||||
stats := collectSourceStats{}
|
||||
for _, item := range items {
|
||||
url := strings.TrimSpace(item.URL)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenURL[url]; ok {
|
||||
continue
|
||||
}
|
||||
seenURL[url] = struct{}{}
|
||||
query := strings.TrimSpace(item.Query)
|
||||
if query != "" {
|
||||
seenQuery[query] = struct{}{}
|
||||
}
|
||||
textRunes := len([]rune(strings.TrimSpace(item.Title))) + len([]rune(strings.TrimSpace(item.Snippet)))
|
||||
stats.TextRunes += textRunes
|
||||
if textRunes >= minUsefulSourceRunes {
|
||||
stats.UsefulSources++
|
||||
}
|
||||
}
|
||||
stats.UniqueURLs = len(seenURL)
|
||||
stats.QueryCoverage = len(seenQuery)
|
||||
return stats
|
||||
}
|
||||
|
||||
func searchWebQuery(
|
||||
|
|
@ -244,8 +327,11 @@ func searchWebQuery(
|
|||
return items
|
||||
}
|
||||
|
||||
func appendBraveResults(out *[]BraveSource, seenURL map[string]struct{}, query string, items []BraveSource) {
|
||||
func appendBraveResults(out *[]BraveSource, seenURL map[string]struct{}, query string, items []BraveSource, max int) {
|
||||
for _, item := range items {
|
||||
if max > 0 && len(*out) >= max {
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(item.URL)
|
||||
if url == "" {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
package knowledge
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"haixun-backend/internal/library/websearch"
|
||||
)
|
||||
|
||||
func TestUniqueSourceCount(t *testing.T) {
|
||||
count := uniqueSourceCount([]BraveSource{
|
||||
|
|
@ -14,6 +21,61 @@ func TestUniqueSourceCount(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSourcesAreSufficientRequiresProcessedQuality(t *testing.T) {
|
||||
items := make([]BraveSource, 0, 14)
|
||||
for i := 0; i < 14; i++ {
|
||||
items = append(items, BraveSource{
|
||||
Query: "q1",
|
||||
URL: fmt.Sprintf("https://example.com/%d", i),
|
||||
Title: "薄",
|
||||
})
|
||||
}
|
||||
cfg := BraveCollectConfig{MinSourcesBeforeStop: 14, MaxSourcesCap: 22}
|
||||
if sourcesAreSufficient(items, []string{"q1", "q2"}, cfg) {
|
||||
t.Fatal("thin same-query results should not be considered sufficient")
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
items[i].Snippet = "這是一段整理後足以支撐判斷的搜尋摘要內容,包含痛點與情境"
|
||||
if i >= 7 {
|
||||
items[i].Query = "q2"
|
||||
}
|
||||
}
|
||||
if !sourcesAreSufficient(items, []string{"q1", "q2"}, cfg) {
|
||||
t.Fatal("diverse useful results should be considered sufficient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectWebSourcesParallelStopsAfterSufficientBatch(t *testing.T) {
|
||||
client := &fakeSearchClient{results: map[string][]websearch.SearchResult{}}
|
||||
for qi := 0; qi < 5; qi++ {
|
||||
query := fmt.Sprintf("q%d", qi+1)
|
||||
for ri := 0; ri < 7; ri++ {
|
||||
client.results[query] = append(client.results[query], websearch.SearchResult{
|
||||
URL: fmt.Sprintf("https://example.com/%s/%d", query, ri),
|
||||
Title: "有用來源",
|
||||
Snippet: "這是一段整理後足以支撐判斷的搜尋摘要內容,包含痛點與情境",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
out := CollectWebSources(
|
||||
context.Background(),
|
||||
client,
|
||||
BraveSearchLocale{},
|
||||
[]string{"q1", "q2", "q3", "q4", "q5"},
|
||||
BraveCollectConfig{ResultsPerQuery: 7, MinSourcesBeforeStop: 14, MaxSourcesCap: 30, Concurrency: 2},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if len(out) != 14 {
|
||||
t.Fatalf("expected first batch to provide 14 sources, got %d", len(out))
|
||||
}
|
||||
if got := client.callCount(); got != 2 {
|
||||
t.Fatalf("expected collection to stop after first sufficient batch, called %d queries", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanQueriesHybridBudget(t *testing.T) {
|
||||
queries := PlanQueries(PlanInput{
|
||||
Seed: "敏感肌",
|
||||
|
|
@ -102,3 +164,34 @@ func TestSupplementalQueriesSkippedForHybrid(t *testing.T) {
|
|||
t.Fatalf("hybrid supplemental brave should be empty, got %v", queries)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSearchClient struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
results map[string][]websearch.SearchResult
|
||||
}
|
||||
|
||||
func (f *fakeSearchClient) Enabled() bool { return true }
|
||||
|
||||
func (f *fakeSearchClient) Provider() websearch.Provider { return websearch.ProviderBrave }
|
||||
|
||||
func (f *fakeSearchClient) Search(_ context.Context, opts websearch.SearchOptions) (websearch.SearchResponse, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.results == nil {
|
||||
f.results = map[string][]websearch.SearchResult{}
|
||||
}
|
||||
f.calls = append(f.calls, opts.Query)
|
||||
return websearch.SearchResponse{
|
||||
Query: opts.Query,
|
||||
Status: "success",
|
||||
Provider: websearch.ProviderBrave,
|
||||
Results: append([]websearch.SearchResult(nil), f.results[opts.Query]...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeSearchClient) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.calls)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,13 @@ func MinBreadthGraphNodes() int {
|
|||
return minBreadthNodes
|
||||
}
|
||||
|
||||
func KnowledgeGraphJSONOnlyRetryPrompt() string {
|
||||
return strings.TrimSpace(`上次回覆沒有包含可解析的 JSON。請**只**輸出一個 JSON 物件:
|
||||
- 直接以 { 開頭、以 } 結尾,不要任何思考過程、說明文字或 markdown 圍欄
|
||||
- 結構需含 nodes 與 edges 陣列,欄位齊全
|
||||
- 不要使用 ... 省略,也不要加註解或尾端逗號`)
|
||||
}
|
||||
|
||||
func KnowledgeGraphRetryUserPrompt() string {
|
||||
return strings.TrimSpace(`上次產出過於簡略或節點不足。請重新產出完整 TKG JSON:
|
||||
- 節點總數 **24~32 個**,L1≥8、L2≥12(廣度優先,多方向觸及)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
package knowledge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"haixun-backend/internal/library/llmjson"
|
||||
libprompt "haixun-backend/internal/library/prompt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -118,9 +118,9 @@ func ParseSynthOutput(raw string, in SynthInput, sources []BraveSource) (Graph,
|
|||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
var out rawSynthOutput
|
||||
if err := json.Unmarshal(payload, &out); err != nil {
|
||||
return Graph{}, fmt.Errorf("parse knowledge graph json: %w", err)
|
||||
out, err := unmarshalSynthOutput(payload)
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
|
||||
seed := strings.TrimSpace(in.Seed)
|
||||
|
|
@ -322,6 +322,17 @@ func resolveNodeRef(ref string, labelToID map[string]string, nodes []Node) strin
|
|||
return ""
|
||||
}
|
||||
|
||||
// unmarshalSynthOutput parses the knowledge graph payload, tolerating the small
|
||||
// JSON artifacts (e.g. `...` placeholders, trailing/duplicate commas) that LLMs
|
||||
// occasionally emit.
|
||||
func unmarshalSynthOutput(payload []byte) (rawSynthOutput, error) {
|
||||
var out rawSynthOutput
|
||||
if err := llmjson.Unmarshal(payload, &out); err != nil {
|
||||
return rawSynthOutput{}, fmt.Errorf("parse knowledge graph json: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func extractJSONObject(raw string) ([]byte, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,57 @@ func TestExtractJSONObject(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseSynthOutputToleratesEllipsisAndTrailingCommas(t *testing.T) {
|
||||
// LLM emits `...` placeholders, a trailing comma, and a duplicated comma.
|
||||
raw := "```json\n" + `{
|
||||
"nodes": [
|
||||
{
|
||||
"label": "敏感肌泛紅",
|
||||
"nodeKind": "symptom",
|
||||
"type": "symptom",
|
||||
"layer": 1,
|
||||
"relation": "溫差導致泛紅",
|
||||
"evidenceUrls": ["https://example.com/a"],
|
||||
},
|
||||
...,
|
||||
{
|
||||
"label": "保濕不足",
|
||||
"nodeKind": "cause",
|
||||
"layer": 2,
|
||||
"evidenceUrls": [],
|
||||
}
|
||||
],
|
||||
"edges": [...]
|
||||
}` + "\n```"
|
||||
payload, err := extractJSONObject(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("extractJSONObject error: %v", err)
|
||||
}
|
||||
graph, err := ParseSynthOutput(string(payload), SynthInput{Seed: "敏感肌"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSynthOutput error: %v", err)
|
||||
}
|
||||
labels := map[string]bool{}
|
||||
for _, n := range graph.Nodes {
|
||||
labels[n.Label] = true
|
||||
}
|
||||
if !labels["敏感肌泛紅"] || !labels["保濕不足"] {
|
||||
t.Fatalf("expected both real nodes parsed, got %+v", labels)
|
||||
}
|
||||
// The URL containing "//" must survive sanitization.
|
||||
found := false
|
||||
for _, n := range graph.Nodes {
|
||||
for _, ev := range n.Evidence {
|
||||
if ev.URL == "https://example.com/a" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected evidence URL to survive sanitization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSynthOutputPlacementReason(t *testing.T) {
|
||||
raw := `{
|
||||
"nodes": [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
// Package llmjson tolerates the small JSON artifacts that LLMs sometimes emit
|
||||
// (e.g. `...` placeholders, trailing or duplicated commas) so structured
|
||||
// outputs can still be parsed instead of failing the whole job.
|
||||
package llmjson
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Unmarshal parses payload into v, retrying once on a sanitized copy when the
|
||||
// raw bytes are not strictly valid JSON. The original error is returned when
|
||||
// the sanitized retry also fails, so callers keep a meaningful message.
|
||||
func Unmarshal(payload []byte, v any) error {
|
||||
err := json.Unmarshal(payload, v)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if cleaned := Sanitize(payload); cleaned != nil {
|
||||
if retryErr := json.Unmarshal(cleaned, v); retryErr == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Sanitize removes ellipsis placeholders and normalizes stray commas that sit
|
||||
// outside of string literals. It returns nil when nothing changed so callers
|
||||
// can avoid a redundant re-parse. String/URL contents are never altered.
|
||||
func Sanitize(in []byte) []byte {
|
||||
normalized := normalizeCommas(stripEllipsis(string(in)))
|
||||
if normalized == string(in) {
|
||||
return nil
|
||||
}
|
||||
return []byte(normalized)
|
||||
}
|
||||
|
||||
func stripEllipsis(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if inString {
|
||||
b.WriteByte(ch)
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if ch == '\\' {
|
||||
escaped = true
|
||||
} else if ch == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = true
|
||||
b.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if ch == '.' {
|
||||
j := i
|
||||
for j < len(s) && s[j] == '.' {
|
||||
j++
|
||||
}
|
||||
if j-i >= 2 {
|
||||
i = j - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(ch)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func normalizeCommas(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
inString := false
|
||||
escaped := false
|
||||
var lastMeaningful byte
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if inString {
|
||||
b.WriteByte(ch)
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if ch == '\\' {
|
||||
escaped = true
|
||||
} else if ch == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = true
|
||||
b.WriteByte(ch)
|
||||
lastMeaningful = ch
|
||||
continue
|
||||
}
|
||||
if ch == ',' {
|
||||
if lastMeaningful == '[' || lastMeaningful == '{' || lastMeaningful == ',' || lastMeaningful == 0 {
|
||||
continue
|
||||
}
|
||||
if next := nextNonSpaceByte(s, i+1); next == ']' || next == '}' {
|
||||
continue
|
||||
}
|
||||
b.WriteByte(ch)
|
||||
lastMeaningful = ch
|
||||
continue
|
||||
}
|
||||
b.WriteByte(ch)
|
||||
if ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r' {
|
||||
lastMeaningful = ch
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func nextNonSpaceByte(s string, from int) byte {
|
||||
for i := from; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
continue
|
||||
default:
|
||||
return s[i]
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package llmjson
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUnmarshalToleratesEllipsisAndCommas(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"nodes": [
|
||||
{"label": "a", "url": "https://example.com/a"},
|
||||
...,
|
||||
{"label": "b"},
|
||||
],
|
||||
"edges": [...]
|
||||
}`)
|
||||
var out struct {
|
||||
Nodes []struct {
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
} `json:"nodes"`
|
||||
Edges []any `json:"edges"`
|
||||
}
|
||||
if err := Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("Unmarshal error: %v", err)
|
||||
}
|
||||
if len(out.Nodes) != 2 {
|
||||
t.Fatalf("nodes = %d, want 2", len(out.Nodes))
|
||||
}
|
||||
if out.Nodes[0].URL != "https://example.com/a" {
|
||||
t.Fatalf("url mutated: %q", out.Nodes[0].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeReturnsNilWhenValid(t *testing.T) {
|
||||
if got := Sanitize([]byte(`{"a":1}`)); got != nil {
|
||||
t.Fatalf("expected nil for clean json, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeKeepsDotsInsideStrings(t *testing.T) {
|
||||
raw := []byte(`{"note":"see ... and https://a.b/c", "list":[1, ...]}`)
|
||||
cleaned := Sanitize(raw)
|
||||
if cleaned == nil {
|
||||
t.Fatal("expected sanitized output")
|
||||
}
|
||||
var out struct {
|
||||
Note string `json:"note"`
|
||||
List []int `json:"list"`
|
||||
}
|
||||
if err := Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("Unmarshal error: %v", err)
|
||||
}
|
||||
if out.Note != "see ... and https://a.b/c" {
|
||||
t.Fatalf("string content mutated: %q", out.Note)
|
||||
}
|
||||
if len(out.List) != 1 || out.List[0] != 1 {
|
||||
t.Fatalf("list = %v, want [1]", out.List)
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,42 @@ func formatTagList(tags []string) string {
|
|||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func FormatCopyResearchMapBlock(audience, goal string, questions, pillars, exclusions []string) string {
|
||||
func SelectKnowledgeItems(selected, all []string) []string {
|
||||
source := selected
|
||||
if len(source) == 0 {
|
||||
source = all
|
||||
}
|
||||
return dedupeKnowledgeLines(source)
|
||||
}
|
||||
|
||||
// MergeKnowledgeItems combines explicit user selections without falling back to unselected items.
|
||||
func MergeKnowledgeItems(parts ...[]string) []string {
|
||||
merged := make([]string, 0)
|
||||
for _, part := range parts {
|
||||
merged = append(merged, part...)
|
||||
}
|
||||
return dedupeKnowledgeLines(merged)
|
||||
}
|
||||
|
||||
func dedupeKnowledgeLines(lines []string) []string {
|
||||
out := make([]string, 0, len(lines))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range lines {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(item)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func FormatCopyResearchMapBlock(audience, goal string, questions, pillars, exclusions []string, knowledgeItems ...[]string) string {
|
||||
var b strings.Builder
|
||||
if audience = strings.TrimSpace(audience); audience != "" {
|
||||
b.WriteString("受眾:")
|
||||
|
|
@ -76,6 +111,11 @@ func FormatCopyResearchMapBlock(audience, goal string, questions, pillars, exclu
|
|||
b.WriteString(strings.Join(questions, ";"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(knowledgeItems) > 0 && len(knowledgeItems[0]) > 0 {
|
||||
b.WriteString("勾選延伸知識:")
|
||||
b.WriteString(strings.Join(knowledgeItems[0], ";"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(exclusions) > 0 {
|
||||
b.WriteString("排除:")
|
||||
b.WriteString(strings.Join(exclusions, ";"))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package matrix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
|
@ -8,6 +9,7 @@ import (
|
|||
|
||||
libprompt "haixun-backend/internal/library/prompt"
|
||||
"haixun-backend/internal/library/threadspost"
|
||||
domai "haixun-backend/internal/model/ai/domain/usecase"
|
||||
)
|
||||
|
||||
type Row struct {
|
||||
|
|
@ -42,7 +44,7 @@ type GenerateInput struct {
|
|||
Count int
|
||||
}
|
||||
|
||||
var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$")
|
||||
var codeFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
|
||||
|
||||
func BuildUserPrompt(in GenerateInput) (string, error) {
|
||||
count := in.Count
|
||||
|
|
@ -95,15 +97,111 @@ func buildMaterialsBlock(posts []MaterialPost) string {
|
|||
return strings.Join(lines, "\n\n")
|
||||
}
|
||||
|
||||
type TextGenerator interface {
|
||||
GenerateText(ctx context.Context, req domai.GenerateRequest) (*domai.GenerateResult, error)
|
||||
}
|
||||
|
||||
// CopyMatrixGenerateRequest tunes token budget for multi-row JSON matrix output.
|
||||
func CopyMatrixGenerateRequest(base domai.GenerateRequest, count int) domai.GenerateRequest {
|
||||
if count <= 0 {
|
||||
count = 5
|
||||
}
|
||||
temp := 0.45
|
||||
tokens := 8192 + count*1024
|
||||
if tokens > 16384 {
|
||||
tokens = 16384
|
||||
}
|
||||
base.Temperature = &temp
|
||||
base.MaxTokens = &tokens
|
||||
return base
|
||||
}
|
||||
|
||||
func MatrixRetryUserPrompt(count int) string {
|
||||
if count <= 0 {
|
||||
count = 5
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"上一則回覆的 JSON 不完整或無法解析。請只回傳單一 JSON 物件 {\"rows\":[...]},共 %d 篇,不要用 markdown 程式碼區塊、不要加說明文字。\n"+
|
||||
"每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。\n"+
|
||||
"reference_notes 與 rationale 各不超過 40 字;text 建議 80~220 字。",
|
||||
count,
|
||||
)
|
||||
}
|
||||
|
||||
// GenerateCopyOutput calls the LLM and parses matrix JSON, with one compact-json retry.
|
||||
func GenerateCopyOutput(
|
||||
ctx context.Context,
|
||||
ai TextGenerator,
|
||||
req domai.GenerateRequest,
|
||||
count int,
|
||||
) (GenerateResult, error) {
|
||||
genReq := CopyMatrixGenerateRequest(req, count)
|
||||
result, err := ai.GenerateText(ctx, genReq)
|
||||
if err != nil {
|
||||
return GenerateResult{}, err
|
||||
}
|
||||
parsed, parseErr := ParseGenerateOutput(result.Text)
|
||||
if parseErr == nil {
|
||||
if count > 0 && len(parsed.Rows) < count {
|
||||
parseErr = fmt.Errorf("matrix truncated: got %d rows, want %d", len(parsed.Rows), count)
|
||||
} else {
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
retryReq := CopyMatrixGenerateRequest(domai.GenerateRequest{
|
||||
Provider: req.Provider,
|
||||
Model: req.Model,
|
||||
Credential: req.Credential,
|
||||
System: req.System,
|
||||
Messages: append(
|
||||
append([]domai.Message{}, req.Messages...),
|
||||
domai.Message{Role: "assistant", Content: result.Text},
|
||||
domai.Message{Role: "user", Content: MatrixRetryUserPrompt(count)},
|
||||
),
|
||||
}, count)
|
||||
retryResult, retryErr := ai.GenerateText(ctx, retryReq)
|
||||
if retryErr != nil {
|
||||
return GenerateResult{}, fmt.Errorf("matrix retry failed: %w (first parse: %v)", retryErr, parseErr)
|
||||
}
|
||||
retryParsed, retryParseErr := ParseGenerateOutput(retryResult.Text)
|
||||
if retryParseErr != nil {
|
||||
return GenerateResult{}, retryParseErr
|
||||
}
|
||||
if count > 0 && len(retryParsed.Rows) < count {
|
||||
return GenerateResult{}, fmt.Errorf("matrix truncated after retry: got %d rows, want %d", len(retryParsed.Rows), count)
|
||||
}
|
||||
return retryParsed, nil
|
||||
}
|
||||
|
||||
func ParseGenerateOutput(raw string) (GenerateResult, error) {
|
||||
payload, err := extractJSONObject(raw)
|
||||
if err != nil {
|
||||
return GenerateResult{}, err
|
||||
}
|
||||
var out GenerateResult
|
||||
if err := json.Unmarshal(payload, &out); err != nil {
|
||||
out, err := decodeGenerateResult(payload)
|
||||
if err == nil {
|
||||
return normalizeGenerateResult(out)
|
||||
}
|
||||
repaired, repairErr := repairTruncatedJSONObject(payload)
|
||||
if repairErr != nil {
|
||||
return GenerateResult{}, err
|
||||
}
|
||||
out, err = decodeGenerateResult(repaired)
|
||||
if err != nil {
|
||||
return GenerateResult{}, fmt.Errorf("parse matrix json: %w", err)
|
||||
}
|
||||
return normalizeGenerateResult(out)
|
||||
}
|
||||
|
||||
func decodeGenerateResult(payload []byte) (GenerateResult, error) {
|
||||
var out GenerateResult
|
||||
if err := json.Unmarshal(payload, &out); err != nil {
|
||||
return GenerateResult{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) {
|
||||
if len(out.Rows) == 0 {
|
||||
return GenerateResult{}, fmt.Errorf("matrix rows missing")
|
||||
}
|
||||
|
|
@ -134,9 +232,58 @@ func extractJSONObject(raw string) ([]byte, error) {
|
|||
raw = strings.TrimSpace(m[1])
|
||||
}
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start < 0 || end <= start {
|
||||
if start < 0 {
|
||||
return nil, fmt.Errorf("matrix output missing json object")
|
||||
}
|
||||
return []byte(raw[start : end+1]), nil
|
||||
slice := raw[start:]
|
||||
end := strings.LastIndex(slice, "}")
|
||||
if end <= 0 {
|
||||
return []byte(slice), nil
|
||||
}
|
||||
return []byte(slice[:end+1]), nil
|
||||
}
|
||||
|
||||
func repairTruncatedJSONObject(payload []byte) ([]byte, error) {
|
||||
if len(payload) == 0 || payload[0] != '{' {
|
||||
return nil, fmt.Errorf("matrix output missing json object")
|
||||
}
|
||||
stack := make([]byte, 0, 8)
|
||||
inString := false
|
||||
escaped := false
|
||||
for _, b := range payload {
|
||||
if inString {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if b == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if b == '"' {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch b {
|
||||
case '"':
|
||||
inString = true
|
||||
case '{':
|
||||
stack = append(stack, '}')
|
||||
case '[':
|
||||
stack = append(stack, ']')
|
||||
case '}', ']':
|
||||
if len(stack) > 0 && stack[len(stack)-1] == b {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
repaired := append([]byte(nil), payload...)
|
||||
if inString {
|
||||
repaired = append(repaired, '"')
|
||||
}
|
||||
for i := len(stack) - 1; i >= 0; i-- {
|
||||
repaired = append(repaired, stack[i])
|
||||
}
|
||||
return repaired, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ type ViralPostSample struct {
|
|||
|
||||
func FormatViralSamples(posts []ViralPostSample) string {
|
||||
if len(posts) == 0 {
|
||||
return "(尚無海巡樣本,請依研究地圖與標籤發揮)"
|
||||
return "(尚無海巡樣本;請依研究地圖、延伸知識與人設產出,不要編造參考貼文)"
|
||||
}
|
||||
var b strings.Builder
|
||||
limit := 8
|
||||
|
|
|
|||
|
|
@ -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 連線後再發布貼文"
|
||||
}
|
||||
|
|
@ -33,20 +33,20 @@ func (m MemberContext) CrawlerBlocked() bool {
|
|||
|
||||
// CrawlerFallbackAllowed returns true when crawler may be used after API/web search fails.
|
||||
func (m MemberContext) CrawlerFallbackAllowed() bool {
|
||||
if !m.AllowsCrawler || !m.BrowserConnected {
|
||||
if !m.BrowserConnected {
|
||||
return false
|
||||
}
|
||||
switch m.SearchSourceMode {
|
||||
case SearchSourceThreadsCrawler, SearchSourceBraveCrawler, SearchSourceMixed:
|
||||
case SearchSourceThreadsCrawler, SearchSourceBraveCrawler, SearchSourceMixed, SearchSourceThreads, SearchSourceBrave, SearchSourceThreadsBrave:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
return m.AllowsCrawler
|
||||
}
|
||||
}
|
||||
|
||||
// HasDiscoverPath reports whether at least one discover backend is configured and connected.
|
||||
func (m MemberContext) HasDiscoverPath() bool {
|
||||
if m.AllowsCrawler && m.BrowserConnected {
|
||||
if m.BrowserConnected {
|
||||
return true
|
||||
}
|
||||
if m.AllowsThreadsAPI && m.ApiConnected {
|
||||
|
|
@ -79,6 +79,9 @@ func (m MemberContext) DiscoverPathLabel() string {
|
|||
}
|
||||
|
||||
func discoverMissingPathError(m MemberContext) error {
|
||||
if m.BrowserConnected {
|
||||
return fmt.Errorf("Chrome Session 已同步,可使用爬蟲海巡;請重新整理後再試")
|
||||
}
|
||||
switch m.SearchSourceMode {
|
||||
case SearchSourceCrawler:
|
||||
return fmt.Errorf("請先同步 Chrome Session 以使用爬蟲搜尋")
|
||||
|
|
|
|||
|
|
@ -26,6 +26,19 @@ func TestShouldTryCrawlerFirst_threadsOnly(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSessionIsDiscoverPathEvenWhenModeIsThreadsOnly(t *testing.T) {
|
||||
m := MemberContext{
|
||||
BrowserConnected: true,
|
||||
SearchSourceMode: SearchSourceThreads,
|
||||
}
|
||||
if !m.HasDiscoverPath() {
|
||||
t.Fatal("browser session should be enough for discover capability")
|
||||
}
|
||||
if !m.CrawlerFallbackAllowed() {
|
||||
t.Fatal("browser session should be available as crawler fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMemberContextFormalModeKeepsCrawlerMode(t *testing.T) {
|
||||
prefs := ConnectionPrefsInput{
|
||||
DevMode: false,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
package placement
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"haixun-backend/internal/library/llmjson"
|
||||
)
|
||||
|
||||
type ResearchMap struct {
|
||||
|
|
@ -205,6 +206,13 @@ func BuildResearchMapUserPrompt(in ResearchMapInput) string {
|
|||
return BuildResearchMapFinalizeUserPrompt(in, "")
|
||||
}
|
||||
|
||||
func ResearchMapJSONOnlyRetryPrompt() string {
|
||||
return strings.TrimSpace(`上次回覆沒有包含可解析的 JSON。請**只**輸出一個 JSON 物件:
|
||||
- 直接以 { 開頭、以 } 結尾,不要任何思考過程、說明文字或 markdown 圍欄
|
||||
- 需含 audienceSummary、contentGoal、questions、pillars、exclusions、patrolKeywords 欄位
|
||||
- 不要使用 ... 省略,也不要加註解或尾端逗號`)
|
||||
}
|
||||
|
||||
func ResearchMapRetryUserPrompt() string {
|
||||
return strings.TrimSpace(`上次產出過於簡略或項目不足。請重新產出完整 JSON,密度對齊系統 prompt 優秀範例:
|
||||
- 必須緊扣【輸入資料】中的品牌、產品與主題目標,不可照搬範例用字
|
||||
|
|
@ -220,7 +228,7 @@ func ParseResearchMapOutput(raw string) (ResearchMap, error) {
|
|||
return ResearchMap{}, err
|
||||
}
|
||||
var out ResearchMap
|
||||
if err := json.Unmarshal(payload, &out); err != nil {
|
||||
if err := llmjson.Unmarshal(payload, &out); err != nil {
|
||||
return ResearchMap{}, fmt.Errorf("parse research map json: %w", err)
|
||||
}
|
||||
out.AudienceSummary = strings.TrimSpace(out.AudienceSummary)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
你是 Threads 內容矩陣策劃師,為人設帳號產出多篇可發佈草稿。
|
||||
|
||||
規則:
|
||||
- 只回傳 JSON,格式為 {"rows":[...]}。
|
||||
- 只回傳單一 JSON 物件 {"rows":[...]},不要用 markdown 程式碼區塊,不要加前言或結語。
|
||||
- reference_notes、rationale 各 ≤ 40 字;優先把 token 用在 text 主文。
|
||||
- 每篇必須角度不同,避免重複 hook。
|
||||
- 套用人設語氣與 8D,不要寫成品牌廣告或硬銷。
|
||||
- 參考爆款樣本只學結構與節奏,不抄原文。
|
||||
- 人設 8D 是最高優先:語氣、人稱、禁忌必須完全符合;不要寫成品牌廣告或硬銷。
|
||||
- 內容論點來自研究地圖與使用者勾選的延伸知識(含標籤與細節);爆款樣本只學結構與節奏,不抄原文。
|
||||
- 繁體中文,口語自然,適合 Threads。
|
||||
- 每篇 text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)。
|
||||
- 爆款互動最佳 80~220 字:前 1~2 行強 hook,一句一重點;超過 300 字互動通常下降。
|
||||
|
|
@ -3,16 +3,25 @@
|
|||
任務主題:{{topic_label}}
|
||||
Brief:{{topic_brief}}
|
||||
|
||||
研究地圖:
|
||||
{{research_map_block}}
|
||||
|
||||
已選海巡標籤:
|
||||
{{selected_tags_block}}
|
||||
|
||||
爆款樣本(只學結構):
|
||||
{{viral_samples_block}}
|
||||
|
||||
人設 8D:
|
||||
人設 8D(最高優先,語氣與禁忌以此為準):
|
||||
{{persona_block}}
|
||||
|
||||
研究地圖與勾選延伸知識:
|
||||
{{research_map_block}}
|
||||
|
||||
搜尋查詢/人工參考方向:
|
||||
{{selected_tags_block}}
|
||||
|
||||
參考網頁 Markdown 已併入上方研究地圖區塊(若有)。
|
||||
|
||||
爆款樣本(可選,只學結構;若沒有樣本,請改用研究地圖與人設原創):
|
||||
{{viral_samples_block}}
|
||||
|
||||
請先遵守:
|
||||
- **最高優先:人設 8D** — 語氣、人稱、句型、節奏、禁忌必須完全符合人設;讀起來要像這個帳號在說話。
|
||||
- **內容素材**:研究地圖(受眾、目標、問題、支柱)與「勾選延伸知識」(含標籤、細節)決定論點與角度;若有「參考網頁(Markdown)」區塊,優先從中提取事實與論點,再融入草稿。
|
||||
- 搜尋查詢/標籤僅作方向參考,不可蓋過人設與勾選知識。
|
||||
- 爆款樣本只學結構節奏,不抄原文,不可為仿爆款偏離任務主題。
|
||||
- 若沒有爆款樣本,source_permalinks 回傳空陣列,reference_notes 寫明使用了哪些人設特徵、研究地圖重點與勾選延伸知識。
|
||||
|
||||
回傳 JSON rows,每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。
|
||||
|
|
@ -37,19 +37,11 @@ func ParseStoredProfile(raw string) (*StoredProfile, bool) {
|
|||
return &profile, true
|
||||
}
|
||||
|
||||
// HasReady8D returns true when 8D analysis exists and can drive copy generation.
|
||||
// HasReady8D returns true when D1–D8 summaries exist and can drive copy generation.
|
||||
// personaDraft or legacy persona text alone is not sufficient for matrix/copy gates.
|
||||
func HasReady8D(personaText, styleProfileJSON string) bool {
|
||||
if profile, ok := ParseStoredProfile(styleProfileJSON); ok {
|
||||
if strings.TrimSpace(profile.PersonaDraft) != "" {
|
||||
return true
|
||||
}
|
||||
for _, key := range dimensionOrder {
|
||||
if summary := strings.TrimSpace(profile.Analysis[key].Summary); summary != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(personaText) != "" && strings.TrimSpace(styleProfileJSON) != ""
|
||||
_ = personaText
|
||||
return BuildStyle8DPromptBlock(styleProfileJSON) != ""
|
||||
}
|
||||
|
||||
// BuildStyle8DPromptBlock formats D1–D8 summaries for LLM prompts.
|
||||
|
|
|
|||
|
|
@ -24,3 +24,10 @@ func TestHasReady8DFromAnalysisOnly(t *testing.T) {
|
|||
t.Fatal("expected ready from analysis summary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasReady8DRejectsPersonaDraftOnly(t *testing.T) {
|
||||
raw := `{"personaDraft":"【我是誰】\n生活觀察者"}`
|
||||
if HasReady8D("語氣描述", raw) {
|
||||
t.Fatal("personaDraft without 8D dimensions should not be ready")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -20,6 +20,9 @@ const (
|
|||
type DiscoverInput struct {
|
||||
Keywords []string
|
||||
Exclusions []string
|
||||
SeedQuery string
|
||||
Label string
|
||||
TopicHints []string
|
||||
Member placement.MemberContext
|
||||
Crawler placement.CrawlerSearchFn
|
||||
Limit int // per keyword; 0 = default
|
||||
|
|
@ -56,6 +59,7 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
|
|||
relaxed := map[string]placement.ScanCandidate{}
|
||||
total := len(keywords)
|
||||
pathLabel := input.Member.DiscoverPathLabel()
|
||||
topicTerms := missionTopicMatchTerms(input.SeedQuery, input.Label, input.TopicHints)
|
||||
var lastErr error
|
||||
keywordsAttempted := 0
|
||||
|
||||
|
|
@ -114,6 +118,9 @@ func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn)
|
|||
Priority: PriorityLabel(score),
|
||||
}
|
||||
if input.MissionScan {
|
||||
if !missionPostMatchesTopic(candidate, topicTerms) {
|
||||
continue
|
||||
}
|
||||
if PassesMissionQualityCandidate(
|
||||
post.Text, post.LikeCount, post.ReplyCount, score,
|
||||
post.AuthorVerified, post.FollowerCount, input.Exclusions,
|
||||
|
|
|
|||
|
|
@ -6,12 +6,17 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
libthreads "haixun-backend/internal/library/threadsapi"
|
||||
"haixun-backend/internal/library/websearch"
|
||||
)
|
||||
|
||||
const (
|
||||
maxAccountDiscoverQueries = 2
|
||||
MaxSimilarAccounts = 5
|
||||
// Single web-search query per supplement pass — fewer API calls, same recall
|
||||
// via a combined site: + seed + brief/pillar hint in one request.
|
||||
maxAccountDiscoverQueries = 1
|
||||
MaxSimilarAccounts = 10
|
||||
// Skip web-search supplement when scan already surfaced enough reference authors.
|
||||
MinSimilarAccountsBeforeWebSupplement = 5
|
||||
)
|
||||
|
||||
var threadsProfileRE = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([a-zA-Z0-9._]+)`)
|
||||
|
|
@ -26,6 +31,7 @@ type SimilarAccount struct {
|
|||
Username string `json:"username"`
|
||||
Reason string `json:"reason"`
|
||||
Source string `json:"source"`
|
||||
MatchedSource []string `json:"matchedSource,omitempty"`
|
||||
Confidence string `json:"confidence"`
|
||||
ProfileURL string `json:"profileUrl"`
|
||||
}
|
||||
|
|
@ -41,6 +47,7 @@ type accountCandidate struct {
|
|||
score int
|
||||
reason string
|
||||
source string
|
||||
permalink string
|
||||
}
|
||||
|
||||
func DiscoverSimilarAccounts(ctx context.Context, client websearch.Client, input DiscoverAccountsInput) ([]SimilarAccount, error) {
|
||||
|
|
@ -85,15 +92,20 @@ func DiscoverSimilarAccounts(ctx context.Context, client websearch.Client, input
|
|||
}
|
||||
key := strings.ToLower(username)
|
||||
prev, ok := seen[key]
|
||||
permalink := strings.TrimSpace(item.URL)
|
||||
if !ok || weight > prev.score {
|
||||
seen[key] = accountCandidate{
|
||||
username: username,
|
||||
score: weight,
|
||||
reason: reason,
|
||||
source: "web",
|
||||
permalink: permalink,
|
||||
}
|
||||
} else if ok {
|
||||
prev.score += 1
|
||||
if prev.permalink == "" && permalink != "" {
|
||||
prev.permalink = permalink
|
||||
}
|
||||
seen[key] = prev
|
||||
}
|
||||
}
|
||||
|
|
@ -111,49 +123,41 @@ func DiscoverSimilarAccounts(ctx context.Context, client websearch.Client, input
|
|||
|
||||
accounts := make([]SimilarAccount, 0, len(out))
|
||||
for _, item := range out {
|
||||
profileURL := libthreads.ProfileURLFromPermalink(item.permalink, item.username)
|
||||
accounts = append(accounts, SimilarAccount{
|
||||
Username: item.username,
|
||||
Reason: item.reason,
|
||||
Source: item.source,
|
||||
MatchedSource: []string{item.source},
|
||||
Confidence: accountConfidence(item.score),
|
||||
ProfileURL: "https://www.threads.net/@" + item.username,
|
||||
ProfileURL: profileURL,
|
||||
})
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func buildAccountDiscoverQueries(seed, brief string, pillars []string) []string {
|
||||
quoted := `"` + seed + `"`
|
||||
queries := []string{
|
||||
`site:threads.net ` + quoted,
|
||||
`threads ` + quoted + ` 創作者`,
|
||||
seed = strings.TrimSpace(seed)
|
||||
if seed == "" {
|
||||
return nil
|
||||
}
|
||||
quoted := `"` + seed + `"`
|
||||
query := `site:threads.net ` + quoted
|
||||
if hint := strings.TrimSpace(brief); len([]rune(hint)) >= 4 && len([]rune(hint)) <= 24 {
|
||||
queries = append(queries, `site:threads.net `+quoted+` `+hint)
|
||||
query += ` ` + hint
|
||||
}
|
||||
for _, pillar := range pillars {
|
||||
pillar = strings.TrimSpace(pillar)
|
||||
if len([]rune(pillar)) >= 4 && len(queries) < maxAccountDiscoverQueries+1 {
|
||||
queries = append(queries, `site:threads.net "`+pillar+`"`)
|
||||
}
|
||||
}
|
||||
unique := []string{}
|
||||
seen := map[string]struct{}{}
|
||||
for _, q := range queries {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[q]; ok {
|
||||
continue
|
||||
}
|
||||
seen[q] = struct{}{}
|
||||
unique = append(unique, q)
|
||||
if len(unique) >= maxAccountDiscoverQueries {
|
||||
if len([]rune(pillar)) >= 4 {
|
||||
query += ` "` + pillar + `"`
|
||||
break
|
||||
}
|
||||
}
|
||||
return unique
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{query}
|
||||
}
|
||||
|
||||
func extractUsernames(blob string) []string {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package viral
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractUsernames(t *testing.T) {
|
||||
blob := `See https://www.threads.net/@creator_one and threads.com/@creator_two/posts/abc`
|
||||
|
|
@ -19,9 +22,15 @@ func TestIsValidUsernameRejectsReserved(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildAccountDiscoverQueriesCapsAtTwo(t *testing.T) {
|
||||
queries := buildAccountDiscoverQueries("轉職", "想找語錄", []string{"支柱A", "支柱B", "支柱C"})
|
||||
if len(queries) > maxAccountDiscoverQueries {
|
||||
t.Fatalf("expected at most %d queries, got %d", maxAccountDiscoverQueries, len(queries))
|
||||
func TestBuildAccountDiscoverQueriesSingleCombinedQuery(t *testing.T) {
|
||||
queries := buildAccountDiscoverQueries("轉職", "想找語錄", []string{"職場語錄", "支柱B"})
|
||||
if len(queries) != 1 {
|
||||
t.Fatalf("expected 1 combined query, got %d (%v)", len(queries), queries)
|
||||
}
|
||||
if !strings.Contains(queries[0], `site:threads.net`) || !strings.Contains(queries[0], `"轉職"`) {
|
||||
t.Fatalf("unexpected query: %q", queries[0])
|
||||
}
|
||||
if !strings.Contains(queries[0], "想找語錄") || !strings.Contains(queries[0], `"職場語錄"`) {
|
||||
t.Fatalf("brief/pillar hint missing from combined query: %q", queries[0])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,3 +70,49 @@ func TestRunDiscover_missionRelaxedFallbackWithoutVerified(t *testing.T) {
|
|||
t.Fatal("verified should remain false when API omits it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDiscover_missionFiltersOffTopicCrawlerDrift(t *testing.T) {
|
||||
crawler := func(ctx context.Context, m placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
|
||||
return []placement.DiscoverPost{
|
||||
{
|
||||
Text: "毛小孩飲食心得分享,最近很多人問罐罐怎麼挑",
|
||||
Author: "pet_user",
|
||||
LikeCount: 200,
|
||||
ReplyCount: 40,
|
||||
Permalink: "https://www.threads.net/@pet_user/post/pet",
|
||||
Source: placement.DiscoverCrawler,
|
||||
},
|
||||
{
|
||||
Text: "備孕精蟲品質心得分享:作息、壓力和檢查報告怎麼一起看",
|
||||
Author: "fertility_user",
|
||||
LikeCount: 26,
|
||||
ReplyCount: 6,
|
||||
Permalink: "https://www.threads.net/@fertility_user/post/quality",
|
||||
Source: placement.DiscoverCrawler,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
member := placement.MemberContext{
|
||||
AllowsCrawler: true,
|
||||
BrowserConnected: true,
|
||||
SearchSourceMode: placement.SearchSourceCrawler,
|
||||
}
|
||||
out, err := RunDiscover(context.Background(), DiscoverInput{
|
||||
Keywords: []string{"精蟲品質"},
|
||||
SeedQuery: "精蟲品質",
|
||||
Label: "備孕內容",
|
||||
TopicHints: []string{"備孕", "精蟲品質"},
|
||||
Member: member,
|
||||
Crawler: crawler,
|
||||
MissionScan: true,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected only topic-matched candidate, got %d", len(out))
|
||||
}
|
||||
if out[0].Author != "fertility_user" {
|
||||
t.Fatalf("expected fertility candidate, got %q", out[0].Author)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"haixun-backend/internal/library/placement"
|
||||
libthreads "haixun-backend/internal/library/threadsapi"
|
||||
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
|
||||
)
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ func EnrichSimilarAccounts(existing []missionentity.SimilarAccount, posts []plac
|
|||
username string
|
||||
score int
|
||||
text string
|
||||
permalink string
|
||||
}
|
||||
authors := map[string]authorScore{}
|
||||
for _, post := range posts {
|
||||
|
|
@ -43,6 +45,9 @@ func EnrichSimilarAccounts(existing []missionentity.SimilarAccount, posts []plac
|
|||
if prev.text == "" && strings.TrimSpace(post.Text) != "" {
|
||||
prev.text = strings.TrimSpace(post.Text)
|
||||
}
|
||||
if prev.permalink == "" {
|
||||
prev.permalink = strings.TrimSpace(post.Permalink)
|
||||
}
|
||||
authors[key] = prev
|
||||
}
|
||||
ranked := make([]authorScore, 0, len(authors))
|
||||
|
|
@ -73,8 +78,9 @@ func EnrichSimilarAccounts(existing []missionentity.SimilarAccount, posts []plac
|
|||
Username: item.username,
|
||||
Reason: reason,
|
||||
Source: "scan",
|
||||
MatchedSource: []string{"scan"},
|
||||
Confidence: conf,
|
||||
ProfileURL: "https://www.threads.net/@" + item.username,
|
||||
ProfileURL: libthreads.ProfileURLFromPermalink(item.permalink, item.username),
|
||||
}
|
||||
order = append(order, key)
|
||||
}
|
||||
|
|
@ -116,10 +122,15 @@ func AccountTagsFromSimilar(accounts []missionentity.SimilarAccount, max int) []
|
|||
func ToEntitySimilarAccounts(items []SimilarAccount) []missionentity.SimilarAccount {
|
||||
out := make([]missionentity.SimilarAccount, 0, len(items))
|
||||
for _, item := range items {
|
||||
matched := item.MatchedSource
|
||||
if len(matched) == 0 && item.Source != "" {
|
||||
matched = []string{item.Source}
|
||||
}
|
||||
out = append(out, missionentity.SimilarAccount{
|
||||
Username: item.Username,
|
||||
Reason: item.Reason,
|
||||
Source: item.Source,
|
||||
MatchedSource: matched,
|
||||
Confidence: item.Confidence,
|
||||
ProfileURL: item.ProfileURL,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
package viral
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"haixun-backend/internal/library/placement"
|
||||
)
|
||||
|
||||
type MissionInspireInput struct {
|
||||
|
|
@ -17,6 +21,7 @@ type MissionInspireInput struct {
|
|||
PersonaPillars []string
|
||||
RecentMissionLabels []string
|
||||
RecentSeedQueries []string
|
||||
UserKeyword string
|
||||
TrendSnippets []MissionInspireTrendSnippet
|
||||
WebSearchProvider string
|
||||
LLMOnly bool
|
||||
|
|
@ -29,6 +34,16 @@ type MissionInspireTrendSnippet struct {
|
|||
URL string
|
||||
}
|
||||
|
||||
type MissionInspireThreadsInput struct {
|
||||
Keyword string
|
||||
PersonaBrief string
|
||||
StyleBenchmark string
|
||||
Pillars []string
|
||||
Questions []string
|
||||
Member placement.MemberContext
|
||||
Crawler placement.CrawlerSearchFn
|
||||
}
|
||||
|
||||
type MissionInspireOutput struct {
|
||||
Label string
|
||||
SeedQuery string
|
||||
|
|
@ -38,10 +53,10 @@ type MissionInspireOutput struct {
|
|||
}
|
||||
|
||||
func BuildMissionInspireSystemPrompt() string {
|
||||
return strings.TrimSpace(`你是 Threads 拷貝忍者的「靈感骰子」顧問。根據近期網路熱搜、Google Trends 類訊號與創作者人設,產出一組**全新**拷貝任務草稿。
|
||||
return strings.TrimSpace(`你是 Threads 拷貝忍者的「靈感骰子」顧問。根據近期 Threads 熱門貼文、網路搜尋訊號與創作者人設,產出一組**全新**拷貝任務草稿。
|
||||
|
||||
規則:
|
||||
1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;不要編造不存在的時事
|
||||
1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;不要編造不存在的時事或熱門貼文
|
||||
2. 若趨勢訊號為空(未連線網路搜尋),**必須**改依人設、受眾痛點與常見 Threads 討論型態推測「近期可能被搜尋」的話題,並在 trendReason 說明推測理由(不要假裝有外部熱搜來源)
|
||||
3. label:任務名稱,6~18 字,像企劃案標題,不要標點堆疊
|
||||
4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點
|
||||
|
|
@ -85,6 +100,11 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
|
|||
b.WriteString(strings.Join(in.PersonaPillars, "、"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if keyword := strings.TrimSpace(in.UserKeyword); keyword != "" {
|
||||
b.WriteString("【使用者指定靈感關鍵字】")
|
||||
b.WriteString(keyword)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 {
|
||||
b.WriteString("【近期已做過的任務(請避開)】\n")
|
||||
for _, label := range in.RecentMissionLabels {
|
||||
|
|
@ -103,11 +123,11 @@ func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
|
|||
}
|
||||
}
|
||||
if in.LLMOnly {
|
||||
b.WriteString("【模式】未設定 Web Search API key,請純依人設與受眾推測靈感(勿假裝有 Google 熱搜)\n")
|
||||
b.WriteString("【模式】未取得 Threads/API/搜尋素材,請純依人設、使用者關鍵字與受眾推測靈感(勿假裝有外部熱搜)\n")
|
||||
} else if provider := strings.TrimSpace(in.WebSearchProvider); provider != "" {
|
||||
b.WriteString("【趨勢來源】")
|
||||
b.WriteString(provider)
|
||||
b.WriteString(" 網路搜尋\n")
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("【近期趨勢訊號】\n")
|
||||
if len(in.TrendSnippets) == 0 {
|
||||
|
|
@ -148,6 +168,152 @@ func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
|
|||
return queries
|
||||
}
|
||||
|
||||
func InspireThreadsSearchQueries(in MissionInspireThreadsInput) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, 6)
|
||||
add := func(q string) {
|
||||
q = cleanInspireQuery(q)
|
||||
if q == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[q]; ok {
|
||||
return
|
||||
}
|
||||
seen[q] = struct{}{}
|
||||
out = append(out, q)
|
||||
}
|
||||
|
||||
keyword := cleanInspireQuery(in.Keyword)
|
||||
if keyword != "" {
|
||||
add(keyword)
|
||||
add(keyword + " 心情")
|
||||
add(keyword + " 經驗")
|
||||
add(keyword + " 請問")
|
||||
}
|
||||
for _, pillar := range in.Pillars {
|
||||
if len(out) >= 6 {
|
||||
break
|
||||
}
|
||||
add(pillar)
|
||||
if keyword != "" {
|
||||
add(keyword + " " + pillar)
|
||||
}
|
||||
}
|
||||
for _, question := range in.Questions {
|
||||
if len(out) >= 6 {
|
||||
break
|
||||
}
|
||||
add(question)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
context := strings.TrimSpace(in.PersonaBrief + " " + in.StyleBenchmark)
|
||||
if len([]rune(context)) > 24 {
|
||||
context = string([]rune(context)[:24])
|
||||
}
|
||||
add(context)
|
||||
}
|
||||
if len(out) > 6 {
|
||||
return out[:6]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CollectMissionInspireThreadsTrends(ctx context.Context, in MissionInspireThreadsInput) []MissionInspireTrendSnippet {
|
||||
if !in.Member.HasDiscoverPath() {
|
||||
return nil
|
||||
}
|
||||
if !in.Member.AllowsCrawler && !in.Member.AllowsThreadsAPI {
|
||||
return nil
|
||||
}
|
||||
queries := InspireThreadsSearchQueries(in)
|
||||
if len(queries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type scoredSnippet struct {
|
||||
item MissionInspireTrendSnippet
|
||||
score int
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
collected := make([]scoredSnippet, 0, 12)
|
||||
for _, query := range queries {
|
||||
posts, _, err := placement.Discover(ctx, placement.DiscoverRequest{
|
||||
Query: query,
|
||||
Keyword: query,
|
||||
Limit: 8,
|
||||
Member: in.Member,
|
||||
Crawler: in.Crawler,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, post := range posts {
|
||||
text := strings.TrimSpace(post.Text)
|
||||
if len([]rune(text)) < 18 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(post.Permalink)
|
||||
if key == "" {
|
||||
key = strings.TrimSpace(post.ExternalID)
|
||||
}
|
||||
if key == "" {
|
||||
key = text
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
title := strings.TrimSpace(post.Author)
|
||||
if title != "" {
|
||||
title = "Threads @" + strings.TrimPrefix(title, "@")
|
||||
} else {
|
||||
title = "Threads 熱門貼文"
|
||||
}
|
||||
collected = append(collected, scoredSnippet{
|
||||
item: MissionInspireTrendSnippet{
|
||||
Query: query,
|
||||
Title: title,
|
||||
Snippet: shortenRunes(text, 180),
|
||||
URL: strings.TrimSpace(post.Permalink),
|
||||
},
|
||||
score: post.LikeCount + post.ReplyCount*2,
|
||||
})
|
||||
}
|
||||
if len(collected) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.SliceStable(collected, func(i, j int) bool {
|
||||
return collected[i].score > collected[j].score
|
||||
})
|
||||
if len(collected) > 8 {
|
||||
collected = collected[:8]
|
||||
}
|
||||
out := make([]MissionInspireTrendSnippet, 0, len(collected))
|
||||
for _, item := range collected {
|
||||
out = append(out, item.item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cleanInspireQuery(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.ReplaceAll(raw, "\n", " ")
|
||||
raw = strings.Join(strings.Fields(raw), " ")
|
||||
if len([]rune(raw)) > 24 {
|
||||
raw = string([]rune(raw)[:24])
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func shortenRunes(raw string, max int) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if max <= 0 || len([]rune(raw)) <= max {
|
||||
return raw
|
||||
}
|
||||
return string([]rune(raw)[:max]) + "…"
|
||||
}
|
||||
|
||||
func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) {
|
||||
payload, err := extractCopyMapJSON(raw)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func TestBuildMissionInspireUserPromptLLMOnly(t *testing.T) {
|
|||
PersonaBrief: "職場焦慮",
|
||||
LLMOnly: true,
|
||||
})
|
||||
if !strings.Contains(prompt, "未設定 Web Search API key") {
|
||||
if !strings.Contains(prompt, "未取得 Threads/API/搜尋素材") {
|
||||
t.Fatalf("expected llm-only hint in prompt: %s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "無外部趨勢結果") {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"haixun-backend/internal/library/llmjson"
|
||||
)
|
||||
|
||||
// MissionResearchMap is the structured copy-mission research map (single LLM call).
|
||||
|
|
@ -18,19 +20,20 @@ type MissionResearchMap struct {
|
|||
}
|
||||
|
||||
func BuildMissionResearchMapSystemPrompt() string {
|
||||
return strings.TrimSpace(`你是 Threads 爆款/對標研究顧問。目標:幫創作者找到「近期熱門、高互動、值得仿寫」的話題與搜尋方向。
|
||||
return strings.TrimSpace(`你是 Threads 拷貝任務的「延伸知識與相似帳號研究顧問」。目標:把使用者的完整意圖延伸成可產文的知識地圖,並提供適合人工參考的相似帳號搜尋方向;不要把題目壓扁成泛泛短詞。
|
||||
|
||||
規則:
|
||||
1. audienceSummary:必填,2~4 句描述「受眾是誰」(年齡/情境/痛點/會在 Threads 搜什麼),不要只寫人設本人
|
||||
2. 聚焦「最近會在 Threads 被搜、被討論」的話題,不要寫成學術報告
|
||||
3. contentGoal:找到近期互動佳、結構可模仿的爆款貼文
|
||||
4. pillars:可模仿方向(語錄型、故事型、清單型等),至少 4 個;**必須是字串陣列**,不要用 {title:...} 物件
|
||||
5. questions:受眾會搜的短問題,5+ 個;字串陣列
|
||||
6. exclusions:不要模仿的內容,至少 4 個;字串陣列
|
||||
7. suggestedTags:6~8 個「像真人會在 Threads 搜尋框打的字」
|
||||
- 每個含 tag, reason, searchIntent(痛點|知識|經驗|對比|工具|語錄), searchType(短詞|情境|語錄)
|
||||
- tag 2~10 字,不要標點、不要完整句子、不要像文章標題
|
||||
8. benchmarkNotes:怎樣算值得仿的爆款(互動、hook 清楚)
|
||||
2. 必須保留使用者原始題目的核心主詞、情境與目的。例如「備孕男生要吃什麼保健品」不可簡化成「老公吃什麼」
|
||||
3. contentGoal:用延伸知識與人設產出可發的 Threads 內容;爆款貼文只作為可選參考,不是必經來源
|
||||
4. pillars:延伸知識支柱,至少 4 個;**必須是字串陣列**,每項要能支撐一篇內容,例如營養素、檢查、生活習慣、常見迷思
|
||||
5. questions:受眾會搜尋或想問的完整問題,5+ 個;字串陣列,需保留主詞與情境
|
||||
6. exclusions:不要寫的內容,至少 4 個;包含過度醫療承諾、偏方、恐嚇、與題目無關的家庭日常
|
||||
7. suggestedTags:6~8 個「搜尋查詢」,不是短 hashtag
|
||||
- 每個含 tag, reason, searchIntent(痛點|知識|經驗|對比|工具|帳號), searchType(查詢|情境|帳號)
|
||||
- tag 6~22 字,必須保留核心意圖;可以像真人搜尋句,例如「備孕男生保健品」「精蟲品質吃什麼」「男性備孕葉酸鋅」
|
||||
- 不要輸出「老公吃什麼」「多閱讀」這種失去主題的泛詞
|
||||
8. benchmarkNotes:整理延伸知識重點與人工看相似帳號時的觀察方向;可列 3~5 個重點
|
||||
9. 繁體中文;只回傳 JSON(含 audienceSummary)`)
|
||||
}
|
||||
|
||||
|
|
@ -68,17 +71,25 @@ func BuildMissionResearchMapUserPrompt(in CopyResearchMapInput) string {
|
|||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n請產出拷貝任務研究地圖 JSON(必填 audienceSummary 與 suggestedTags 陣列)。")
|
||||
b.WriteString("\n請產出拷貝任務研究地圖 JSON。請把 suggestedTags 當成完整搜尋查詢,不要壓成短 hashtag。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func MissionResearchMapJSONOnlyRetryPrompt() string {
|
||||
return strings.TrimSpace(`上次回覆沒有包含可解析的 JSON。請**只**輸出一個 JSON 物件:
|
||||
- 直接以 { 開頭、以 } 結尾,不要任何思考過程、說明文字或 markdown 圍欄
|
||||
- 需含 audienceSummary、contentGoal、questions、pillars、exclusions、suggestedTags、benchmarkNotes 欄位
|
||||
- suggestedTags 至少 4 項,每項含 tag/reason/searchIntent/searchType
|
||||
- 不要使用 ... 省略,也不要加註解或尾端逗號`)
|
||||
}
|
||||
|
||||
func ParseMissionResearchMapOutput(raw string) (MissionResearchMap, error) {
|
||||
payload, err := extractCopyMapJSON(raw)
|
||||
if err != nil {
|
||||
return MissionResearchMap{}, err
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &root); err != nil {
|
||||
if err := llmjson.Unmarshal(payload, &root); err != nil {
|
||||
return MissionResearchMap{}, fmt.Errorf("parse mission research map: %w", err)
|
||||
}
|
||||
out := MissionResearchMap{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package viral
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMissionResearchMapOutput_ObjectPillars(t *testing.T) {
|
||||
raw := `{
|
||||
|
|
@ -53,3 +56,13 @@ func TestParseMissionResearchMapOutput_StringSuggestedTags(t *testing.T) {
|
|||
t.Fatalf("expected 4 tags, got %#v", out.SuggestedTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMissionResearchMapSystemPromptPreservesIntent(t *testing.T) {
|
||||
prompt := BuildMissionResearchMapSystemPrompt()
|
||||
if !strings.Contains(prompt, "不可簡化成「老公吃什麼」") {
|
||||
t.Fatalf("expected prompt to warn against intent-flattening, got %s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "搜尋查詢") {
|
||||
t.Fatalf("expected suggested tags to be framed as search queries")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,13 @@ package viral
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"haixun-backend/internal/library/clock"
|
||||
"haixun-backend/internal/library/placement"
|
||||
libthreads "haixun-backend/internal/library/threadsapi"
|
||||
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
|
||||
)
|
||||
|
||||
|
|
@ -17,11 +20,63 @@ const (
|
|||
RefVerifiedMinBestLikes = 10
|
||||
)
|
||||
|
||||
// ReferenceRankWeights controls the weighted sort key applied to ranked
|
||||
// reference authors. Defaults preserve historical precedence (verified first,
|
||||
// then follower count, then total engagement, then best single-post
|
||||
// engagement) and introduce topic relevance as a tie-breaker multiplier.
|
||||
type ReferenceRankWeights struct {
|
||||
VerifiedW int
|
||||
FollowerW int
|
||||
TotalEngagementW int
|
||||
BestEngagementW int
|
||||
TopicRelevanceW int
|
||||
}
|
||||
|
||||
// DefaultReferenceRankWeights returns the canonical weights used by
|
||||
// BuildReferenceAccountsFromScan. Callers may pass a customised copy via
|
||||
// ReferenceAccountInput in a future iteration; Phase 1 keeps it internal.
|
||||
func DefaultReferenceRankWeights() ReferenceRankWeights {
|
||||
return ReferenceRankWeights{
|
||||
VerifiedW: 4,
|
||||
FollowerW: 2,
|
||||
TotalEngagementW: 1,
|
||||
BestEngagementW: 1,
|
||||
TopicRelevanceW: 2,
|
||||
}
|
||||
}
|
||||
|
||||
// rankScore converts aggregated author signals into a weighted integer sort
|
||||
// key. Follower count is log-scaled so 10M-follower mega accounts do not
|
||||
// dominate niche candidates with high topic relevance.
|
||||
func (w ReferenceRankWeights) rankScore(item referenceAuthorAgg) int {
|
||||
score := 0
|
||||
if item.verified {
|
||||
score += w.VerifiedW * 1000
|
||||
}
|
||||
followerBucket := 0
|
||||
switch {
|
||||
case item.followerCount >= 1_000_000:
|
||||
followerBucket = 4
|
||||
case item.followerCount >= 100_000:
|
||||
followerBucket = 3
|
||||
case item.followerCount >= 10_000:
|
||||
followerBucket = 2
|
||||
case item.followerCount >= 1_000:
|
||||
followerBucket = 1
|
||||
}
|
||||
score += w.FollowerW * followerBucket * 100
|
||||
score += w.TotalEngagementW * item.totalEngagement
|
||||
score += w.BestEngagementW * item.bestEngagement
|
||||
score += w.TopicRelevanceW * item.topicHits * 50
|
||||
return score
|
||||
}
|
||||
|
||||
type ReferenceAccountInput struct {
|
||||
SeedQuery string
|
||||
Label string
|
||||
Posts []placement.ScanCandidate
|
||||
Limit int
|
||||
ExcludedUsernames []string
|
||||
}
|
||||
|
||||
type referenceAuthorAgg struct {
|
||||
|
|
@ -33,8 +88,10 @@ type referenceAuthorAgg struct {
|
|||
bestLikes int
|
||||
bestReplies int
|
||||
postCount int
|
||||
topicHits int
|
||||
sampleText string
|
||||
sampleSearchTag string
|
||||
samplePermalink string
|
||||
}
|
||||
|
||||
// BuildReferenceAccountsFromScan lists authors from patrol posts that match the
|
||||
|
|
@ -54,12 +111,20 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool
|
|||
limit = MaxSimilarAccounts
|
||||
}
|
||||
byUser := map[string]referenceAuthorAgg{}
|
||||
terms := normalisedTopicTerms(in.SeedQuery, in.Label)
|
||||
weights := DefaultReferenceRankWeights()
|
||||
now := clock.NowUnixNano()
|
||||
topicDenom := math.Max(1, float64(len(terms)))
|
||||
for _, post := range in.Posts {
|
||||
user := strings.TrimSpace(post.Author)
|
||||
if user == "" || !isValidUsername(user) {
|
||||
continue
|
||||
}
|
||||
if !postTopicRelevant(post, in.SeedQuery, in.Label) {
|
||||
if isExcluded(in.ExcludedUsernames, user) {
|
||||
continue
|
||||
}
|
||||
hits := topicTopicHits(post, terms)
|
||||
if hits == 0 {
|
||||
continue
|
||||
}
|
||||
if strictQuality {
|
||||
|
|
@ -85,6 +150,9 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool
|
|||
}
|
||||
prev.postCount++
|
||||
prev.totalEngagement += post.EngagementScore
|
||||
if hits > prev.topicHits {
|
||||
prev.topicHits = hits
|
||||
}
|
||||
if post.EngagementScore > prev.bestEngagement {
|
||||
prev.bestEngagement = post.EngagementScore
|
||||
prev.bestLikes = post.LikeCount
|
||||
|
|
@ -95,6 +163,7 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool
|
|||
}
|
||||
prev.sampleText = text
|
||||
prev.sampleSearchTag = strings.TrimSpace(post.SearchTag)
|
||||
prev.samplePermalink = strings.TrimSpace(post.Permalink)
|
||||
}
|
||||
byUser[key] = prev
|
||||
}
|
||||
|
|
@ -106,6 +175,13 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool
|
|||
}
|
||||
}
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
si := weights.rankScore(ranked[i])
|
||||
sj := weights.rankScore(ranked[j])
|
||||
if si != sj {
|
||||
return si > sj
|
||||
}
|
||||
// stable historical tie-breakers (verified first, follower,
|
||||
// total engagement, best engagement) preserved for determinism.
|
||||
if ranked[i].verified != ranked[j].verified {
|
||||
return ranked[i].verified
|
||||
}
|
||||
|
|
@ -133,8 +209,12 @@ func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool
|
|||
Username: item.username,
|
||||
Reason: formatReferenceReason(item),
|
||||
Source: "scan",
|
||||
MatchedSource: []string{"scan"},
|
||||
Confidence: conf,
|
||||
ProfileURL: "https://www.threads.net/@" + item.username,
|
||||
Status: missionentity.SimilarAccountStatusRecommended,
|
||||
TopicRelevance: float64(item.topicHits) / topicDenom,
|
||||
LastSeenAt: now,
|
||||
ProfileURL: libthreads.ProfileURLFromPermalink(item.samplePermalink, item.username),
|
||||
AuthorVerified: item.verified,
|
||||
FollowerCount: item.followerCount,
|
||||
EngagementScore: item.bestEngagement,
|
||||
|
|
@ -164,28 +244,53 @@ func qualifiesReferenceAuthor(item referenceAuthorAgg, strictQuality bool) bool
|
|||
}
|
||||
|
||||
func postTopicRelevant(post placement.ScanCandidate, seed, label string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(post.Text))
|
||||
tag := strings.ToLower(strings.TrimSpace(post.SearchTag))
|
||||
terms := topicTerms(seed, label)
|
||||
if len(terms) == 0 {
|
||||
return text != "" || tag != ""
|
||||
}
|
||||
for _, term := range terms {
|
||||
term = strings.ToLower(term)
|
||||
if strings.Contains(text, term) || strings.Contains(tag, term) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return topicTopicHits(post, normalisedTopicTerms(seed, label)) > 0
|
||||
}
|
||||
|
||||
func topicTerms(seed, label string) []string {
|
||||
out := []string{}
|
||||
if s := strings.TrimSpace(seed); s != "" {
|
||||
out = append(out, s)
|
||||
// topicTopicHits counts how many normalised topic terms appear (case-folded
|
||||
// substring match against the post's text and search tag). Returning a hit
|
||||
// count (instead of a boolean) lets the ranking weight reward posts that are
|
||||
// relevant across multiple seed/label tokens — a coarse but dependency-free
|
||||
// CJK-friendly proxy for topic similarity.
|
||||
func topicTopicHits(post placement.ScanCandidate, terms []string) int {
|
||||
if len(terms) == 0 {
|
||||
text := strings.TrimSpace(post.Text)
|
||||
tag := strings.TrimSpace(post.SearchTag)
|
||||
if text == "" && tag == "" {
|
||||
return 0
|
||||
}
|
||||
if l := strings.TrimSpace(label); l != "" {
|
||||
out = append(out, l)
|
||||
return 1
|
||||
}
|
||||
text := strings.ToLower(strings.TrimSpace(post.Text))
|
||||
tag := strings.ToLower(strings.TrimSpace(post.SearchTag))
|
||||
hits := 0
|
||||
for _, term := range terms {
|
||||
if term == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(text, term) || strings.Contains(tag, term) {
|
||||
hits++
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// normalisedTopicTerms lowercases and de-spaces the seed-query and label while
|
||||
// also exposing coarse tokens split on whitespace and punctuation. It remains
|
||||
// dependency-free and CJK-friendly, but avoids matching only a long exact phrase.
|
||||
func normalisedTopicTerms(seed, label string) []string {
|
||||
out := []string{}
|
||||
terms := missionTopicMatchTerms(seed, label, nil)
|
||||
seen := map[string]struct{}{}
|
||||
for _, term := range append(append([]string{}, terms.Anchors...), terms.Terms...) {
|
||||
if term == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[term]; ok {
|
||||
continue
|
||||
}
|
||||
seen[term] = struct{}{}
|
||||
out = append(out, term)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ type SuggestedTag struct {
|
|||
SearchType string `json:"searchType,omitempty"`
|
||||
}
|
||||
|
||||
// PickDefaultSelectedTags chooses a lean set for scanning (saves search API quota).
|
||||
// PickDefaultSelectedTags chooses a lean set of search queries. Prefer complete
|
||||
// intent-preserving queries over short hashtags; short generic tags tend to
|
||||
// drift away from niche copy-mission topics.
|
||||
func PickDefaultSelectedTags(tags []SuggestedTag) []string {
|
||||
short := []string{}
|
||||
scenario := []string{}
|
||||
quote := []string{}
|
||||
query := []string{}
|
||||
contextual := []string{}
|
||||
account := []string{}
|
||||
|
||||
for _, item := range tags {
|
||||
|
|
@ -34,17 +36,19 @@ func PickDefaultSelectedTags(tags []SuggestedTag) []string {
|
|||
continue
|
||||
}
|
||||
switch st {
|
||||
case "短詞":
|
||||
case "查詢", "情境":
|
||||
query = append(query, tag)
|
||||
case "短詞", "語錄":
|
||||
if len([]rune(tag)) >= 6 {
|
||||
query = append(query, tag)
|
||||
} else {
|
||||
short = append(short, tag)
|
||||
case "情境":
|
||||
scenario = append(scenario, tag)
|
||||
case "語錄":
|
||||
quote = append(quote, tag)
|
||||
}
|
||||
default:
|
||||
if len([]rune(tag)) <= 4 {
|
||||
short = append(short, tag)
|
||||
} else {
|
||||
scenario = append(scenario, tag)
|
||||
contextual = append(contextual, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -62,20 +66,14 @@ func PickDefaultSelectedTags(tags []SuggestedTag) []string {
|
|||
seen[tag] = struct{}{}
|
||||
out = append(out, tag)
|
||||
}
|
||||
for _, tag := range short {
|
||||
if len(out) >= 2 {
|
||||
break
|
||||
}
|
||||
add(tag)
|
||||
}
|
||||
for _, tag := range scenario {
|
||||
for _, tag := range query {
|
||||
if len(out) >= 4 {
|
||||
break
|
||||
}
|
||||
add(tag)
|
||||
}
|
||||
for _, tag := range quote {
|
||||
if len(out) >= 5 {
|
||||
for _, tag := range contextual {
|
||||
if len(out) >= 4 {
|
||||
break
|
||||
}
|
||||
add(tag)
|
||||
|
|
@ -86,13 +84,19 @@ func PickDefaultSelectedTags(tags []SuggestedTag) []string {
|
|||
}
|
||||
add(tag)
|
||||
}
|
||||
for _, tag := range short {
|
||||
for _, tag := range query {
|
||||
if len(out) >= DefaultSelectedTagCount {
|
||||
break
|
||||
}
|
||||
add(tag)
|
||||
}
|
||||
for _, tag := range scenario {
|
||||
for _, tag := range contextual {
|
||||
if len(out) >= DefaultSelectedTagCount {
|
||||
break
|
||||
}
|
||||
add(tag)
|
||||
}
|
||||
for _, tag := range short {
|
||||
if len(out) >= DefaultSelectedTagCount {
|
||||
break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,3 +27,20 @@ func TestPickDefaultSelectedTags(t *testing.T) {
|
|||
seen[tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDefaultSelectedTags_prefersIntentPreservingQueries(t *testing.T) {
|
||||
tags := []SuggestedTag{
|
||||
{Tag: "老公吃什麼", SearchType: "短詞"},
|
||||
{Tag: "多閱讀", SearchType: "短詞"},
|
||||
{Tag: "備孕男生保健品", SearchType: "查詢"},
|
||||
{Tag: "精蟲品質吃什麼", SearchType: "查詢"},
|
||||
{Tag: "男性備孕葉酸鋅", SearchType: "情境"},
|
||||
}
|
||||
out := PickDefaultSelectedTags(tags)
|
||||
if len(out) < 2 {
|
||||
t.Fatalf("expected selected queries, got %#v", out)
|
||||
}
|
||||
if out[0] != "備孕男生保健品" || out[1] != "精蟲品質吃什麼" {
|
||||
t.Fatalf("expected complete intent queries first, got %#v", out)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 + ")"
|
||||
}
|
||||
|
|
@ -122,6 +122,7 @@ func (a *braveAdapter) Search(ctx context.Context, opts SearchOptions) (SearchRe
|
|||
Mode: mode,
|
||||
Country: firstNonEmpty(opts.Country, a.country),
|
||||
SearchLang: firstNonEmpty(opts.SearchLang, a.searchLang),
|
||||
StartPublishedDate: opts.StartPublishedDate,
|
||||
})
|
||||
return toBraveResponse(res.Query, res.Status, a.provider, res.Results, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -7,7 +7,10 @@ import (
|
|||
|
||||
app "haixun-backend/internal/library/errors"
|
||||
"haixun-backend/internal/library/errors/code"
|
||||
libcopy "haixun-backend/internal/library/copymission"
|
||||
libkg "haixun-backend/internal/library/knowledge"
|
||||
libmatrix "haixun-backend/internal/library/matrix"
|
||||
libweb "haixun-backend/internal/library/webpage"
|
||||
libprompt "haixun-backend/internal/library/prompt"
|
||||
"haixun-backend/internal/library/style8d"
|
||||
domai "haixun-backend/internal/model/ai/domain/usecase"
|
||||
|
|
@ -43,18 +46,22 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mission.Status != string(missionentity.StatusScanned) &&
|
||||
if mission.Status != string(missionentity.StatusMapped) &&
|
||||
mission.Status != string(missionentity.StatusScanned) &&
|
||||
mission.Status != string(missionentity.StatusDrafted) {
|
||||
return nil, app.For(code.Persona).ResInvalidState("請先完成海巡再產出內容矩陣")
|
||||
return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣")
|
||||
}
|
||||
if len(mission.SelectedTags) == 0 {
|
||||
return nil, app.For(code.Persona).InputMissingRequired("請先選擇海巡標籤")
|
||||
if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
|
||||
return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖")
|
||||
}
|
||||
|
||||
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureNoActiveMatrixJob(l.ctx, l.svcCtx, missionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
|
||||
return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
|
||||
}
|
||||
|
|
@ -79,12 +86,33 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
|
|||
return nil, err
|
||||
}
|
||||
samples := matrixSamplesFromScanPosts(posts)
|
||||
researchBlock := libmatrix.FormatCopyResearchMapBlock(
|
||||
graphNodes, graphSources := loadMatrixGraph(l.ctx, l.svcCtx, tenantID, uid, missionID)
|
||||
knowledgeItems := libcopy.MatrixKnowledgeItems(
|
||||
mission.ResearchMap.SelectedKnowledgeItems,
|
||||
mission.ResearchMap.KnowledgeItems,
|
||||
graphNodes,
|
||||
mission.SelectedTags,
|
||||
)
|
||||
researchSources := libcopy.ResearchSourcesFromSummaries(mission.ResearchMap.ResearchItems)
|
||||
refURLs := libcopy.MergeReferenceURLs(
|
||||
libcopy.ReferenceURLsFromSelection(graphNodes, graphSources),
|
||||
researchSources,
|
||||
)
|
||||
researchItemsBlock := libcopy.FormatResearchItemsForMatrix(researchSources)
|
||||
referenceMarkdown := ""
|
||||
if len(refURLs) > 0 {
|
||||
digests := libweb.FetchDigests(l.ctx, refURLs, libweb.FetchOptions{})
|
||||
referenceMarkdown = libmatrix.BuildReferenceMarkdown(digests)
|
||||
}
|
||||
researchBlock := libmatrix.FormatCopyResearchMapBlockWithReferences(
|
||||
mission.ResearchMap.AudienceSummary,
|
||||
mission.ResearchMap.ContentGoal,
|
||||
mission.ResearchMap.Questions,
|
||||
mission.ResearchMap.Pillars,
|
||||
mission.ResearchMap.Exclusions,
|
||||
knowledgeItems,
|
||||
researchItemsBlock,
|
||||
referenceMarkdown,
|
||||
)
|
||||
userPrompt, err := libmatrix.BuildCopyUserPrompt(libmatrix.CopyGenerateInput{
|
||||
Count: count,
|
||||
|
|
@ -110,7 +138,7 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
|
||||
parsed, err := libmatrix.GenerateCopyOutput(l.ctx, l.svcCtx.AI, domai.GenerateRequest{
|
||||
Provider: providerID,
|
||||
Model: credential.Model,
|
||||
Credential: domai.Credential{
|
||||
|
|
@ -120,11 +148,7 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
|
|||
Messages: []domai.Message{
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := libmatrix.ParseGenerateOutput(result.Text)
|
||||
}, count)
|
||||
if err != nil {
|
||||
return nil, app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error())
|
||||
}
|
||||
|
|
@ -169,6 +193,17 @@ func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(
|
|||
}, nil
|
||||
}
|
||||
|
||||
func loadMatrixGraph(ctx context.Context, svcCtx *svc.ServiceContext, tenantID, uid, missionID string) ([]libkg.Node, []libkg.BraveSource) {
|
||||
if svcCtx == nil || svcCtx.KnowledgeGraph == nil {
|
||||
return nil, nil
|
||||
}
|
||||
graph, err := svcCtx.KnowledgeGraph.GetByCopyMission(ctx, tenantID, uid, missionID)
|
||||
if err != nil || graph == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return graph.Nodes, graph.BraveSources
|
||||
}
|
||||
|
||||
func matrixSamplesFromScanPosts(posts []scanpostdomain.ScanPostSummary) string {
|
||||
samples := make([]libmatrix.ViralPostSample, 0, len(posts))
|
||||
for _, post := range posts {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -25,12 +25,13 @@ func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
|||
return &InspireCopyMissionLogic{ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissionsPath) (*types.CopyMissionInspirationData, error) {
|
||||
func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (*types.CopyMissionInspirationData, error) {
|
||||
tenantID, uid, err := actorFrom(l.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
personaID := strings.TrimSpace(req.PersonaID)
|
||||
userKeyword := strings.TrimSpace(req.Keyword)
|
||||
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -59,25 +60,42 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi
|
|||
|
||||
trendSnippets := []libviral.MissionInspireTrendSnippet{}
|
||||
webSearchProvider := ""
|
||||
trendSourceLabel := ""
|
||||
llmOnly := true
|
||||
|
||||
research, researchErr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
|
||||
if researchErr == nil && placement.WebSearchAvailable(research) {
|
||||
if researchErr == nil {
|
||||
memberCtx, memberErr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
|
||||
if memberErr == nil {
|
||||
trendSnippets = libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{
|
||||
Keyword: userKeyword,
|
||||
PersonaBrief: persona.Brief,
|
||||
StyleBenchmark: persona.StyleBenchmark,
|
||||
Pillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
|
||||
Questions: append([]string(nil), persona.CopyResearchMap.Questions...),
|
||||
Member: memberCtx,
|
||||
Crawler: l.makeCrawlerSearchFn(tenantID, uid),
|
||||
})
|
||||
if len(trendSnippets) > 0 {
|
||||
trendSourceLabel = memberCtx.DiscoverPathLabel()
|
||||
llmOnly = false
|
||||
}
|
||||
if len(trendSnippets) == 0 && placement.WebSearchAvailable(research) {
|
||||
webClient := websearch.New(memberCtx.WebSearchConfig())
|
||||
trendSnippets = libviral.CollectMissionInspireTrends(
|
||||
l.ctx,
|
||||
webClient,
|
||||
memberCtx,
|
||||
persona.Brief,
|
||||
persona.Brief+" "+userKeyword,
|
||||
persona.StyleBenchmark,
|
||||
)
|
||||
webSearchProvider = memberCtx.WebSearchProviderLabel()
|
||||
trendSourceLabel = webSearchProvider
|
||||
llmOnly = len(trendSnippets) == 0
|
||||
}
|
||||
}
|
||||
webSearchUsed := len(trendSnippets) > 0
|
||||
}
|
||||
webSearchUsed := len(trendSnippets) > 0 && webSearchProvider != ""
|
||||
|
||||
credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
|
||||
if err != nil {
|
||||
|
|
@ -110,8 +128,9 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi
|
|||
PersonaPillars: append([]string(nil), persona.CopyResearchMap.Pillars...),
|
||||
RecentMissionLabels: recentLabels,
|
||||
RecentSeedQueries: recentSeeds,
|
||||
UserKeyword: userKeyword,
|
||||
TrendSnippets: trendSnippets,
|
||||
WebSearchProvider: webSearchProvider,
|
||||
WebSearchProvider: trendSourceLabel,
|
||||
LLMOnly: llmOnly,
|
||||
}),
|
||||
},
|
||||
|
|
@ -136,9 +155,12 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi
|
|||
})
|
||||
}
|
||||
|
||||
message := "已依近期趨勢產出任務靈感,可微調後建立任務"
|
||||
if !webSearchUsed {
|
||||
message = "未設定搜尋 API key,已改由 LLM 依人設推測靈感;補上 Brave/Exa key 可取得更貼近熱搜的結果"
|
||||
message := "已依近期 Threads 熱門素材產出任務靈感,可微調後建立任務"
|
||||
if webSearchUsed {
|
||||
message = "已依近期網路趨勢產出任務靈感,可微調後建立任務"
|
||||
}
|
||||
if len(trendSnippets) == 0 {
|
||||
message = "未取得外部趨勢素材,已改由 LLM 依人設與關鍵字推測靈感;同步 Chrome Session、完成 Threads API 或補上 Brave/Exa key 可更貼近熱搜"
|
||||
}
|
||||
|
||||
return &types.CopyMissionInspirationData{
|
||||
|
|
@ -152,3 +174,17 @@ func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.PersonaCopyMissi
|
|||
Message: message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *InspireCopyMissionLogic) makeCrawlerSearchFn(tenantID, ownerUID string) placement.CrawlerSearchFn {
|
||||
return func(ctx context.Context, member placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
|
||||
accountID := strings.TrimSpace(member.ActiveAccountID)
|
||||
if accountID == "" {
|
||||
return nil, app.For(code.Setting).InputMissingRequired("請先選定經營帳號並同步 Chrome Session")
|
||||
}
|
||||
session, err := l.svcCtx.ThreadsAccount.GetBrowserSession(ctx, tenantID, ownerUID, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return placement.RunExecCrawlerSearch(ctx, session.StorageState, keyword, limit)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,14 @@ func toMissionPatch(req *types.UpdateCopyMissionReq) missiondomain.MissionPatch
|
|||
patch.ExclusionsSet = true
|
||||
patch.Exclusions = append([]string(nil), req.Exclusions...)
|
||||
}
|
||||
if req.KnowledgeItems != nil {
|
||||
patch.KnowledgeItemsSet = true
|
||||
patch.KnowledgeItems = append([]string(nil), req.KnowledgeItems...)
|
||||
}
|
||||
if req.SelectedKnowledgeItems != nil {
|
||||
patch.SelectedKnowledgeItemsSet = true
|
||||
patch.SelectedKnowledgeItems = append([]string(nil), req.SelectedKnowledgeItems...)
|
||||
}
|
||||
if req.BenchmarkNotes != nil {
|
||||
patch.BenchmarkNotes = req.BenchmarkNotes
|
||||
}
|
||||
|
|
@ -74,6 +82,19 @@ func toMissionPatch(req *types.UpdateCopyMissionReq) missiondomain.MissionPatch
|
|||
return patch
|
||||
}
|
||||
|
||||
func toResearchItemData(items []missiondomain.ResearchItemSummary) []types.CopyMissionResearchItemData {
|
||||
out := make([]types.CopyMissionResearchItemData, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, types.CopyMissionResearchItemData{
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Snippet: item.Snippet,
|
||||
Query: item.Query,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData {
|
||||
tags := make([]types.CopySuggestedTagData, 0, len(item.ResearchMap.SuggestedTags))
|
||||
for _, tag := range item.ResearchMap.SuggestedTags {
|
||||
|
|
@ -90,7 +111,11 @@ func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData
|
|||
Username: acc.Username,
|
||||
Reason: acc.Reason,
|
||||
Source: acc.Source,
|
||||
MatchedSource: append([]string(nil), acc.MatchedSource...),
|
||||
Confidence: acc.Confidence,
|
||||
Status: acc.Status,
|
||||
TopicRelevance: acc.TopicRelevance,
|
||||
LastSeenAt: acc.LastSeenAt,
|
||||
ProfileUrl: acc.ProfileURL,
|
||||
AuthorVerified: acc.AuthorVerified,
|
||||
FollowerCount: acc.FollowerCount,
|
||||
|
|
@ -100,6 +125,19 @@ func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData
|
|||
PostCount: acc.PostCount,
|
||||
})
|
||||
}
|
||||
samples := make([]types.CopyAudienceSampleData, 0, len(item.ResearchMap.AudienceSamples))
|
||||
for _, sample := range item.ResearchMap.AudienceSamples {
|
||||
samples = append(samples, types.CopyAudienceSampleData{
|
||||
Username: sample.Username,
|
||||
SamplePostId: sample.SamplePostID,
|
||||
SampleText: sample.SampleText,
|
||||
ReplyLikeCount: sample.ReplyLikeCount,
|
||||
Appearances: sample.Appearances,
|
||||
FirstSeenAt: sample.FirstSeenAt,
|
||||
LastSeenAt: sample.LastSeenAt,
|
||||
Status: sample.Status,
|
||||
})
|
||||
}
|
||||
return types.CopyMissionData{
|
||||
ID: item.ID,
|
||||
PersonaID: item.PersonaID,
|
||||
|
|
@ -112,8 +150,12 @@ func toCopyMissionData(item missiondomain.MissionSummary) types.CopyMissionData
|
|||
Questions: append([]string(nil), item.ResearchMap.Questions...),
|
||||
Pillars: append([]string(nil), item.ResearchMap.Pillars...),
|
||||
Exclusions: append([]string(nil), item.ResearchMap.Exclusions...),
|
||||
KnowledgeItems: append([]string(nil), item.ResearchMap.KnowledgeItems...),
|
||||
SelectedKnowledgeItems: append([]string(nil), item.ResearchMap.SelectedKnowledgeItems...),
|
||||
ResearchItems: toResearchItemData(item.ResearchMap.ResearchItems),
|
||||
SuggestedTags: tags,
|
||||
SimilarAccounts: accounts,
|
||||
AudienceSamples: samples,
|
||||
BenchmarkNotes: item.ResearchMap.BenchmarkNotes,
|
||||
},
|
||||
SelectedTags: append([]string(nil), item.SelectedTags...),
|
||||
|
|
@ -151,7 +193,11 @@ func toEntityResearchMap(data types.CopyMissionResearchMapData) missionentity.Re
|
|||
Username: acc.Username,
|
||||
Reason: acc.Reason,
|
||||
Source: acc.Source,
|
||||
MatchedSource: append([]string(nil), acc.MatchedSource...),
|
||||
Confidence: acc.Confidence,
|
||||
Status: acc.Status,
|
||||
TopicRelevance: acc.TopicRelevance,
|
||||
LastSeenAt: acc.LastSeenAt,
|
||||
ProfileURL: acc.ProfileUrl,
|
||||
AuthorVerified: acc.AuthorVerified,
|
||||
FollowerCount: acc.FollowerCount,
|
||||
|
|
@ -161,14 +207,30 @@ func toEntityResearchMap(data types.CopyMissionResearchMapData) missionentity.Re
|
|||
PostCount: acc.PostCount,
|
||||
})
|
||||
}
|
||||
samples := make([]missionentity.AudienceSample, 0, len(data.AudienceSamples))
|
||||
for _, sample := range data.AudienceSamples {
|
||||
samples = append(samples, missionentity.AudienceSample{
|
||||
Username: sample.Username,
|
||||
SamplePostID: sample.SamplePostId,
|
||||
SampleText: sample.SampleText,
|
||||
ReplyLikeCount: sample.ReplyLikeCount,
|
||||
Appearances: sample.Appearances,
|
||||
FirstSeenAt: sample.FirstSeenAt,
|
||||
LastSeenAt: sample.LastSeenAt,
|
||||
Status: sample.Status,
|
||||
})
|
||||
}
|
||||
return missionentity.ResearchMap{
|
||||
AudienceSummary: data.AudienceSummary,
|
||||
ContentGoal: data.ContentGoal,
|
||||
Questions: append([]string(nil), data.Questions...),
|
||||
Pillars: append([]string(nil), data.Pillars...),
|
||||
Exclusions: append([]string(nil), data.Exclusions...),
|
||||
KnowledgeItems: append([]string(nil), data.KnowledgeItems...),
|
||||
SelectedKnowledgeItems: append([]string(nil), data.SelectedKnowledgeItems...),
|
||||
SuggestedTags: tags,
|
||||
SimilarAccounts: accounts,
|
||||
AudienceSamples: samples,
|
||||
BenchmarkNotes: data.BenchmarkNotes,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,9 @@ func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob(
|
|||
if _, err := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"tenant_id": tenantID,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(
|
|||
if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
|
||||
return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
|
||||
}
|
||||
if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
post, err := l.svcCtx.ScanPost.GetForPersona(l.ctx, tenantID, uid, personaID, scanPostID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -35,12 +35,13 @@ func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mission.Status != string(missionentity.StatusScanned) &&
|
||||
if mission.Status != string(missionentity.StatusMapped) &&
|
||||
mission.Status != string(missionentity.StatusScanned) &&
|
||||
mission.Status != string(missionentity.StatusDrafted) {
|
||||
return nil, app.For(code.Persona).ResInvalidState("請先完成海巡再產出內容矩陣")
|
||||
return nil, app.For(code.Persona).ResInvalidState("請先完成研究地圖再產出內容矩陣")
|
||||
}
|
||||
if len(mission.SelectedTags) == 0 {
|
||||
return nil, app.For(code.Persona).InputMissingRequired("請先選擇海巡標籤")
|
||||
if strings.TrimSpace(mission.ResearchMap.AudienceSummary) == "" {
|
||||
return nil, app.For(code.Persona).InputMissingRequired("請先完成研究地圖")
|
||||
}
|
||||
|
||||
persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
|
||||
|
|
@ -50,6 +51,9 @@ func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(
|
|||
if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
|
||||
return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
|
||||
}
|
||||
if err := requireMemberAiReady(l.ctx, l.svcCtx, tenantID, uid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
count := req.Count
|
||||
if count <= 0 {
|
||||
|
|
@ -60,6 +64,8 @@ func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(
|
|||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"tenant_id": tenantID,
|
||||
"owner_uid": uid,
|
||||
"persona_id": personaID,
|
||||
"copy_mission_id": missionID,
|
||||
"count": count,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -84,7 +84,11 @@ func (p *OpenAICompatible) GenerateText(ctx context.Context, req domai.GenerateR
|
|||
return nil, app.For(code.AI).InputMissingRequired("missing AI provider token")
|
||||
}
|
||||
|
||||
body := p.buildChatCompletionRequest(req, false)
|
||||
// Stream even for one-shot generation: long outputs (e.g. knowledge graph /
|
||||
// research map) can exceed the provider's Cloudflare ~100s origin timeout in
|
||||
// non-stream mode and surface as HTTP 524. Streaming keeps bytes flowing and
|
||||
// we accumulate them back into a single result.
|
||||
body := p.buildChatCompletionRequest(req, true)
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -96,7 +100,7 @@ func (p *OpenAICompatible) GenerateText(ctx context.Context, req domai.GenerateR
|
|||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+req.Credential.APIKey)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
httpReq.Header.Set("Accept", "text/event-stream")
|
||||
|
||||
resp, err := p.client.Do(httpReq)
|
||||
if err != nil {
|
||||
|
|
@ -104,26 +108,70 @@ func (p *OpenAICompatible) GenerateText(ctx context.Context, req domai.GenerateR
|
|||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
return nil, providerHTTPError("AI provider request failed", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
var chatResp openAIChatResponse
|
||||
if err := json.Unmarshal(data, &chatResp); err != nil {
|
||||
return accumulateChatStream(resp.Body)
|
||||
}
|
||||
|
||||
// accumulateChatStream consumes an OpenAI-compatible SSE stream and folds the
|
||||
// chunks back into a single GenerateResult. Final answer content and reasoning
|
||||
// are kept apart so reasoning never gets prepended to (and corrupts) the parsed
|
||||
// answer; reasoning is only used as a fallback when no content was emitted.
|
||||
func accumulateChatStream(body io.Reader) (*domai.GenerateResult, error) {
|
||||
scanner := bufio.NewScanner(body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
var content strings.Builder
|
||||
var reasoning strings.Builder
|
||||
finishReason := ""
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
var chunk openAIStreamChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return nil, app.For(code.AI).SvcThirdParty("failed to parse AI provider chat response")
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return nil, app.For(code.AI).SvcThirdParty("AI provider returned no choices")
|
||||
for _, choice := range chunk.Choices {
|
||||
delta := choice.Delta
|
||||
if delta.Content != "" {
|
||||
content.WriteString(delta.Content)
|
||||
} else if delta.Text != "" {
|
||||
content.WriteString(delta.Text)
|
||||
}
|
||||
if delta.ReasoningContent != "" {
|
||||
reasoning.WriteString(delta.ReasoningContent)
|
||||
} else if delta.Reasoning != "" {
|
||||
reasoning.WriteString(delta.Reasoning)
|
||||
}
|
||||
if choice.FinishReason != "" {
|
||||
finishReason = choice.FinishReason
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, app.For(code.AI).SvcThirdParty("AI provider stream read failed: " + err.Error())
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
text := content.String()
|
||||
if strings.TrimSpace(text) == "" {
|
||||
text = reasoning.String()
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, app.For(code.AI).SvcThirdParty("AI provider returned no choices")
|
||||
}
|
||||
return &domai.GenerateResult{
|
||||
Text: extractAssistantText(choice.Message),
|
||||
FinishReason: choice.FinishReason,
|
||||
Text: text,
|
||||
FinishReason: finishReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ func TestOpenAICompatible_GenerateText_UsesContent(t *testing.T) {
|
|||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
t.Fatalf("unmarshal request: %v", err)
|
||||
}
|
||||
if req.Stream {
|
||||
t.Fatal("expected non-stream request")
|
||||
if !req.Stream {
|
||||
t.Fatal("expected stream request to avoid upstream 524 on long generations")
|
||||
}
|
||||
if req.Thinking == nil || req.Thinking.Type != "disabled" {
|
||||
t.Fatalf("thinking = %+v, want disabled for deepseek on opencode", req.Thinking)
|
||||
|
|
@ -69,7 +69,10 @@ func TestOpenAICompatible_GenerateText_UsesContent(t *testing.T) {
|
|||
if req.MaxTokens == nil || *req.MaxTokens < 2048 {
|
||||
t.Fatalf("max_tokens = %+v, want >= 2048 for deepseek on opencode", req.MaxTokens)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"hello"},"finish_reason":"stop"}]}`))
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
@ -92,7 +95,9 @@ func TestOpenAICompatible_GenerateText_UsesContent(t *testing.T) {
|
|||
|
||||
func TestOpenAICompatible_GenerateText_FallsBackToReasoningContent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","reasoning_content":"thinking only"},"finish_reason":"length"}]}`))
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"thinking only\"},\"finish_reason\":\"length\"}]}\n\n"))
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type CopyDraft struct {
|
|||
CopyMissionID string `bson:"copy_mission_id,omitempty"`
|
||||
ScanPostID string `bson:"scan_post_id,omitempty"`
|
||||
DraftType string `bson:"draft_type"`
|
||||
MatrixBatchID string `bson:"matrix_batch_id,omitempty"`
|
||||
SortOrder int `bson:"sort_order,omitempty"`
|
||||
Text string `bson:"text"`
|
||||
Angle string `bson:"angle,omitempty"`
|
||||
|
|
|
|||
|
|
@ -12,8 +12,15 @@ type Repository interface {
|
|||
CreateMany(ctx context.Context, drafts []*entity.CopyDraft) error
|
||||
Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*entity.CopyDraft, error)
|
||||
Update(ctx context.Context, tenantID, ownerUID, personaID, draftID string, patch map[string]interface{}) (*entity.CopyDraft, error)
|
||||
Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error
|
||||
DeleteMany(ctx context.Context, tenantID, ownerUID, personaID, missionID, draftType string, draftIDs []string) (int64, error)
|
||||
DeleteByMissionAndType(ctx context.Context, tenantID, ownerUID, personaID, missionID, draftType string) error
|
||||
DeleteByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error
|
||||
List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.CopyDraft, error)
|
||||
ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]entity.CopyDraft, error)
|
||||
ReplaceMissionMatrix(
|
||||
ctx context.Context,
|
||||
tenantID, ownerUID, personaID, missionID string,
|
||||
drafts []*entity.CopyDraft,
|
||||
) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@ type UpdateRequest struct {
|
|||
Patch CopyDraftPatch
|
||||
}
|
||||
|
||||
type DeleteMissionMatrixDraftsRequest struct {
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
PersonaID string
|
||||
MissionID string
|
||||
DraftIDs []string
|
||||
}
|
||||
|
||||
type UseCase interface {
|
||||
Create(ctx context.Context, req CreateRequest) (*CopyDraftSummary, error)
|
||||
CreateMany(ctx context.Context, req CreateManyRequest) ([]CopyDraftSummary, error)
|
||||
|
|
@ -80,5 +88,7 @@ type UseCase interface {
|
|||
MarkPublished(ctx context.Context, req MarkPublishedRequest) (*CopyDraftSummary, error)
|
||||
List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]CopyDraftSummary, error)
|
||||
ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]CopyDraftSummary, error)
|
||||
Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error
|
||||
DeleteMissionMatrixDrafts(ctx context.Context, req DeleteMissionMatrixDraftsRequest) (int, error)
|
||||
ClearByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
"go.mongodb.org/mongo-driver/mongo/writeconcern"
|
||||
)
|
||||
|
||||
type mongoRepository struct {
|
||||
|
|
@ -159,6 +160,61 @@ func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, person
|
|||
return &out, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error {
|
||||
if r.collection == nil {
|
||||
return app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
draftID = strings.TrimSpace(draftID)
|
||||
if draftID == "" {
|
||||
return app.For(code.Persona).InputMissingRequired("draft_id is required")
|
||||
}
|
||||
filter := personaFilter(tenantID, ownerUID, personaID)
|
||||
filter["_id"] = draftID
|
||||
res, err := r.collection.DeleteOne(ctx, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.DeletedCount == 0 {
|
||||
return app.For(code.Persona).ResNotFound("copy draft not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) DeleteMany(
|
||||
ctx context.Context,
|
||||
tenantID, ownerUID, personaID, missionID, draftType string,
|
||||
draftIDs []string,
|
||||
) (int64, error) {
|
||||
if r.collection == nil {
|
||||
return 0, app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
missionID = strings.TrimSpace(missionID)
|
||||
if missionID == "" {
|
||||
return 0, app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
|
||||
}
|
||||
filter := personaFilter(tenantID, ownerUID, personaID)
|
||||
filter["copy_mission_id"] = missionID
|
||||
if draftType = strings.TrimSpace(draftType); draftType != "" {
|
||||
filter["draft_type"] = draftType
|
||||
}
|
||||
ids := make([]string, 0, len(draftIDs))
|
||||
for _, id := range draftIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
filter["_id"] = bson.M{"$in": ids}
|
||||
}
|
||||
res, err := r.collection.DeleteMany(ctx, filter)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.DeletedCount, nil
|
||||
}
|
||||
|
||||
func (r *mongoRepository) DeleteByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error {
|
||||
if r.collection == nil {
|
||||
return app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
||||
|
|
@ -189,6 +245,99 @@ func (r *mongoRepository) DeleteByMissionAndType(ctx context.Context, tenantID,
|
|||
return err
|
||||
}
|
||||
|
||||
func matrixDraftFilter(tenantID, ownerUID, personaID, missionID string) bson.M {
|
||||
filter := personaFilter(tenantID, ownerUID, personaID)
|
||||
filter["copy_mission_id"] = strings.TrimSpace(missionID)
|
||||
filter["draft_type"] = entity.DraftTypeMatrix
|
||||
return filter
|
||||
}
|
||||
|
||||
func (r *mongoRepository) ReplaceMissionMatrix(
|
||||
ctx context.Context,
|
||||
tenantID, ownerUID, personaID, missionID string,
|
||||
drafts []*entity.CopyDraft,
|
||||
) error {
|
||||
if r.collection == nil {
|
||||
return app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
||||
}
|
||||
missionID = strings.TrimSpace(missionID)
|
||||
if missionID == "" {
|
||||
return app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
|
||||
}
|
||||
if err := r.replaceMissionMatrixWithTransaction(ctx, tenantID, ownerUID, personaID, missionID, drafts); err == nil {
|
||||
return nil
|
||||
}
|
||||
return r.replaceMissionMatrixInsertFirst(ctx, tenantID, ownerUID, personaID, missionID, drafts)
|
||||
}
|
||||
|
||||
func (r *mongoRepository) replaceMissionMatrixWithTransaction(
|
||||
ctx context.Context,
|
||||
tenantID, ownerUID, personaID, missionID string,
|
||||
drafts []*entity.CopyDraft,
|
||||
) error {
|
||||
client := r.collection.Database().Client()
|
||||
session, err := client.StartSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.EndSession(ctx)
|
||||
|
||||
wc := writeconcern.Majority()
|
||||
txnOpts := options.Transaction().SetWriteConcern(wc)
|
||||
_, err = session.WithTransaction(ctx, func(sessCtx mongo.SessionContext) (any, error) {
|
||||
filter := matrixDraftFilter(tenantID, ownerUID, personaID, missionID)
|
||||
if _, err := r.collection.DeleteMany(sessCtx, filter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(drafts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
docs := make([]any, 0, len(drafts))
|
||||
for _, draft := range drafts {
|
||||
if draft == nil {
|
||||
continue
|
||||
}
|
||||
docs = append(docs, draft)
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := r.collection.InsertMany(sessCtx, docs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}, txnOpts)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *mongoRepository) replaceMissionMatrixInsertFirst(
|
||||
ctx context.Context,
|
||||
tenantID, ownerUID, personaID, missionID string,
|
||||
drafts []*entity.CopyDraft,
|
||||
) error {
|
||||
batchID := ""
|
||||
if len(drafts) > 0 {
|
||||
batchID = strings.TrimSpace(drafts[0].MatrixBatchID)
|
||||
}
|
||||
if batchID == "" {
|
||||
filter := matrixDraftFilter(tenantID, ownerUID, personaID, missionID)
|
||||
if _, err := r.collection.DeleteMany(ctx, filter); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(drafts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.CreateMany(ctx, drafts)
|
||||
}
|
||||
if err := r.CreateMany(ctx, drafts); err != nil {
|
||||
return err
|
||||
}
|
||||
filter := matrixDraftFilter(tenantID, ownerUID, personaID, missionID)
|
||||
filter["matrix_batch_id"] = bson.M{"$ne": batchID}
|
||||
_, err := r.collection.DeleteMany(ctx, filter)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *mongoRepository) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]entity.CopyDraft, error) {
|
||||
if r.collection == nil {
|
||||
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
||||
|
|
|
|||
|
|
@ -5,21 +5,24 @@ import (
|
|||
"strings"
|
||||
|
||||
"haixun-backend/internal/library/clock"
|
||||
libcopy "haixun-backend/internal/library/copymission"
|
||||
app "haixun-backend/internal/library/errors"
|
||||
"haixun-backend/internal/library/errors/code"
|
||||
"haixun-backend/internal/model/copy_draft/domain/entity"
|
||||
domrepo "haixun-backend/internal/model/copy_draft/domain/repository"
|
||||
domusecase "haixun-backend/internal/model/copy_draft/domain/usecase"
|
||||
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type copyDraftUseCase struct {
|
||||
repo domrepo.Repository
|
||||
redis *goredis.Client
|
||||
}
|
||||
|
||||
func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
|
||||
return ©DraftUseCase{repo: repo}
|
||||
func NewUseCase(repo domrepo.Repository, redis *goredis.Client) domusecase.UseCase {
|
||||
return ©DraftUseCase{repo: repo, redis: redis}
|
||||
}
|
||||
|
||||
func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.CopyDraftSummary, error) {
|
||||
|
|
@ -202,15 +205,121 @@ func (u *copyDraftUseCase) ReplaceMissionMatrix(
|
|||
if missionID == "" {
|
||||
return nil, app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
|
||||
}
|
||||
if err := u.repo.DeleteByMissionAndType(ctx, tenantID, ownerUID, personaID, missionID, entity.DraftTypeMatrix); err != nil {
|
||||
var out []domusecase.CopyDraftSummary
|
||||
err := libcopy.WithMatrixReplaceLock(ctx, u.redis, tenantID, missionID, ownerUID, func() error {
|
||||
items, buildErr := buildMatrixDraftEntities(tenantID, ownerUID, personaID, missionID, drafts)
|
||||
if buildErr != nil {
|
||||
return buildErr
|
||||
}
|
||||
if replaceErr := u.repo.ReplaceMissionMatrix(ctx, tenantID, ownerUID, personaID, missionID, items); replaceErr != nil {
|
||||
return replaceErr
|
||||
}
|
||||
out = summariesFromEntities(items)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already in progress") {
|
||||
return nil, app.For(code.Persona).ResInvalidState("內容矩陣正在寫入中,請稍後再試")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return u.CreateMany(ctx, domusecase.CreateManyRequest{
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildMatrixDraftEntities(
|
||||
tenantID, ownerUID, personaID, missionID string,
|
||||
drafts []domusecase.CreateRequest,
|
||||
) ([]*entity.CopyDraft, error) {
|
||||
if len(drafts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
batchID := uuid.NewString()
|
||||
now := clock.NowUnixNano()
|
||||
items := make([]*entity.CopyDraft, 0, len(drafts))
|
||||
for idx, draftReq := range drafts {
|
||||
text := strings.TrimSpace(draftReq.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
sortOrder := draftReq.SortOrder
|
||||
if sortOrder == 0 {
|
||||
sortOrder = idx + 1
|
||||
}
|
||||
items = append(items, &entity.CopyDraft{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: tenantID,
|
||||
OwnerUID: ownerUID,
|
||||
PersonaID: personaID,
|
||||
Drafts: drafts,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -20,11 +20,27 @@ type SuggestedTag struct {
|
|||
SearchType string `bson:"search_type,omitempty" json:"search_type,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
SimilarAccountStatusRecommended = "recommended"
|
||||
SimilarAccountStatusPinned = "pinned"
|
||||
SimilarAccountStatusExcluded = "excluded"
|
||||
SimilarAccountStatusPromoted = "promoted"
|
||||
)
|
||||
|
||||
const (
|
||||
AudienceSampleStatusPinned = "pinned"
|
||||
AudienceSampleStatusExcluded = "excluded"
|
||||
)
|
||||
|
||||
type SimilarAccount struct {
|
||||
Username string `bson:"username" json:"username"`
|
||||
Reason string `bson:"reason,omitempty" json:"reason,omitempty"`
|
||||
Source string `bson:"source,omitempty" json:"source,omitempty"`
|
||||
MatchedSource []string `bson:"matched_source,omitempty" json:"matched_source,omitempty"`
|
||||
Confidence string `bson:"confidence,omitempty" json:"confidence,omitempty"`
|
||||
Status string `bson:"status,omitempty" json:"status,omitempty"`
|
||||
TopicRelevance float64 `bson:"topic_relevance,omitempty" json:"topic_relevance,omitempty"`
|
||||
LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
|
||||
ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"`
|
||||
AuthorVerified bool `bson:"author_verified,omitempty" json:"author_verified,omitempty"`
|
||||
FollowerCount int `bson:"follower_count,omitempty" json:"follower_count,omitempty"`
|
||||
|
|
@ -34,14 +50,36 @@ type SimilarAccount struct {
|
|||
PostCount int `bson:"post_count,omitempty" json:"post_count,omitempty"`
|
||||
}
|
||||
|
||||
type ResearchItem struct {
|
||||
Title string `bson:"title,omitempty" json:"title,omitempty"`
|
||||
URL string `bson:"url,omitempty" json:"url,omitempty"`
|
||||
Snippet string `bson:"snippet,omitempty" json:"snippet,omitempty"`
|
||||
Query string `bson:"query,omitempty" json:"query,omitempty"`
|
||||
}
|
||||
|
||||
type AudienceSample struct {
|
||||
Username string `bson:"username" json:"username"`
|
||||
SamplePostID string `bson:"sample_post_id,omitempty" json:"sample_post_id,omitempty"`
|
||||
SampleText string `bson:"sample_text,omitempty" json:"sample_text,omitempty"`
|
||||
ReplyLikeCount int `bson:"reply_like_count,omitempty" json:"reply_like_count,omitempty"`
|
||||
Appearances int `bson:"appearances,omitempty" json:"appearances,omitempty"`
|
||||
FirstSeenAt int64 `bson:"first_seen_at" json:"first_seen_at"`
|
||||
LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
|
||||
Status string `bson:"status,omitempty" json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type ResearchMap struct {
|
||||
AudienceSummary string `bson:"audience_summary,omitempty" json:"audience_summary,omitempty"`
|
||||
ContentGoal string `bson:"content_goal,omitempty" json:"content_goal,omitempty"`
|
||||
Questions []string `bson:"questions,omitempty" json:"questions,omitempty"`
|
||||
Pillars []string `bson:"pillars,omitempty" json:"pillars,omitempty"`
|
||||
Exclusions []string `bson:"exclusions,omitempty" json:"exclusions,omitempty"`
|
||||
KnowledgeItems []string `bson:"knowledge_items,omitempty" json:"knowledge_items,omitempty"`
|
||||
SelectedKnowledgeItems []string `bson:"selected_knowledge_items,omitempty" json:"selected_knowledge_items,omitempty"`
|
||||
ResearchItems []ResearchItem `bson:"research_items,omitempty" json:"research_items,omitempty"`
|
||||
SuggestedTags []SuggestedTag `bson:"suggested_tags,omitempty" json:"suggested_tags,omitempty"`
|
||||
SimilarAccounts []SimilarAccount `bson:"similar_accounts,omitempty" json:"similar_accounts,omitempty"`
|
||||
AudienceSamples []AudienceSample `bson:"audience_samples,omitempty" json:"audience_samples,omitempty"`
|
||||
BenchmarkNotes string `bson:"benchmark_notes,omitempty" json:"benchmark_notes,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +87,7 @@ func (m ResearchMap) IsEmpty() bool {
|
|||
return m.AudienceSummary == "" &&
|
||||
m.ContentGoal == "" &&
|
||||
len(m.Questions) == 0 &&
|
||||
len(m.KnowledgeItems) == 0 &&
|
||||
len(m.SuggestedTags) == 0
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ import (
|
|||
"haixun-backend/internal/model/copy_mission/domain/entity"
|
||||
)
|
||||
|
||||
type ResearchItemSummary struct {
|
||||
Title string
|
||||
URL string
|
||||
Snippet string
|
||||
Query string
|
||||
}
|
||||
|
||||
type SuggestedTagSummary struct {
|
||||
Tag string
|
||||
Reason string
|
||||
|
|
@ -17,7 +24,11 @@ type SimilarAccountSummary struct {
|
|||
Username string
|
||||
Reason string
|
||||
Source string
|
||||
MatchedSource []string
|
||||
Confidence string
|
||||
Status string
|
||||
TopicRelevance float64
|
||||
LastSeenAt int64
|
||||
ProfileURL string
|
||||
AuthorVerified bool
|
||||
FollowerCount int
|
||||
|
|
@ -27,14 +38,29 @@ type SimilarAccountSummary struct {
|
|||
PostCount int
|
||||
}
|
||||
|
||||
type AudienceSampleSummary struct {
|
||||
Username string
|
||||
SamplePostID string
|
||||
SampleText string
|
||||
ReplyLikeCount int
|
||||
Appearances int
|
||||
FirstSeenAt int64
|
||||
LastSeenAt int64
|
||||
Status string
|
||||
}
|
||||
|
||||
type ResearchMapSummary struct {
|
||||
AudienceSummary string
|
||||
ContentGoal string
|
||||
Questions []string
|
||||
Pillars []string
|
||||
Exclusions []string
|
||||
KnowledgeItems []string
|
||||
SelectedKnowledgeItems []string
|
||||
ResearchItems []ResearchItemSummary
|
||||
SuggestedTags []SuggestedTagSummary
|
||||
SimilarAccounts []SimilarAccountSummary
|
||||
AudienceSamples []AudienceSampleSummary
|
||||
BenchmarkNotes string
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +101,10 @@ type MissionPatch struct {
|
|||
Pillars []string
|
||||
ExclusionsSet bool
|
||||
Exclusions []string
|
||||
KnowledgeItemsSet bool
|
||||
KnowledgeItems []string
|
||||
SelectedKnowledgeItemsSet bool
|
||||
SelectedKnowledgeItems []string
|
||||
BenchmarkNotes *string
|
||||
SelectedTagsSet bool
|
||||
SelectedTags []string
|
||||
|
|
@ -95,10 +125,30 @@ type ListResult struct {
|
|||
List []MissionSummary
|
||||
}
|
||||
|
||||
type PatchSimilarAccountRequest struct {
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
PersonaID string
|
||||
MissionID string
|
||||
Username string
|
||||
Status string
|
||||
}
|
||||
|
||||
type PatchAudienceSampleRequest struct {
|
||||
TenantID string
|
||||
OwnerUID string
|
||||
PersonaID string
|
||||
MissionID string
|
||||
Username string
|
||||
Status string
|
||||
}
|
||||
|
||||
type UseCase interface {
|
||||
List(ctx context.Context, tenantID, ownerUID, personaID string) (*ListResult, error)
|
||||
Create(ctx context.Context, req CreateRequest) (*MissionSummary, error)
|
||||
Get(ctx context.Context, tenantID, ownerUID, personaID, missionID string) (*MissionSummary, error)
|
||||
Update(ctx context.Context, req UpdateRequest) (*MissionSummary, error)
|
||||
PatchSimilarAccount(ctx context.Context, req PatchSimilarAccountRequest) (*MissionSummary, error)
|
||||
PatchAudienceSample(ctx context.Context, req PatchAudienceSampleRequest) (*MissionSummary, error)
|
||||
Delete(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,12 @@ func (u *missionUseCase) Update(ctx context.Context, req domusecase.UpdateReques
|
|||
if req.Patch.ExclusionsSet {
|
||||
patch["research_map.exclusions"] = cleanStringList(req.Patch.Exclusions)
|
||||
}
|
||||
if req.Patch.KnowledgeItemsSet {
|
||||
patch["research_map.knowledge_items"] = cleanStringList(req.Patch.KnowledgeItems)
|
||||
}
|
||||
if req.Patch.SelectedKnowledgeItemsSet {
|
||||
patch["research_map.selected_knowledge_items"] = cleanStringList(req.Patch.SelectedKnowledgeItems)
|
||||
}
|
||||
if req.Patch.BenchmarkNotes != nil {
|
||||
patch["research_map.benchmark_notes"] = strings.TrimSpace(*req.Patch.BenchmarkNotes)
|
||||
}
|
||||
|
|
@ -152,6 +158,82 @@ func (u *missionUseCase) Update(ctx context.Context, req domusecase.UpdateReques
|
|||
return &summary, nil
|
||||
}
|
||||
|
||||
func (u *missionUseCase) PatchSimilarAccount(ctx context.Context, req domusecase.PatchSimilarAccountRequest) (*domusecase.MissionSummary, error) {
|
||||
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status, err := validateSimilarAccountStatus(req.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
|
||||
if username == "" {
|
||||
return nil, app.For(code.Facade).InputMissingRequired("username is required")
|
||||
}
|
||||
item, err := u.repo.FindByID(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := false
|
||||
accounts := append([]entity.SimilarAccount(nil), item.ResearchMap.SimilarAccounts...)
|
||||
for i := range accounts {
|
||||
if strings.EqualFold(strings.TrimSpace(accounts[i].Username), username) {
|
||||
accounts[i].Status = status
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, app.For(code.Facade).ResNotFound("similar account not found")
|
||||
}
|
||||
updated, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID, map[string]interface{}{
|
||||
"research_map.similar_accounts": accounts,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary := toSummary(updated)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (u *missionUseCase) PatchAudienceSample(ctx context.Context, req domusecase.PatchAudienceSampleRequest) (*domusecase.MissionSummary, error) {
|
||||
if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status, err := validateAudienceSampleStatus(req.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
|
||||
if username == "" {
|
||||
return nil, app.For(code.Facade).InputMissingRequired("username is required")
|
||||
}
|
||||
item, err := u.repo.FindByID(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found := false
|
||||
samples := append([]entity.AudienceSample(nil), item.ResearchMap.AudienceSamples...)
|
||||
for i := range samples {
|
||||
if strings.EqualFold(strings.TrimSpace(samples[i].Username), username) {
|
||||
samples[i].Status = status
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, app.For(code.Facade).ResNotFound("audience sample not found")
|
||||
}
|
||||
updated, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID, map[string]interface{}{
|
||||
"research_map.audience_samples": samples,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary := toSummary(updated)
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (u *missionUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error {
|
||||
if err := requireActor(tenantID, ownerUID, personaID); err != nil {
|
||||
return err
|
||||
|
|
@ -184,11 +266,16 @@ func toSummary(item *entity.Mission) domusecase.MissionSummary {
|
|||
}
|
||||
accounts := make([]domusecase.SimilarAccountSummary, 0, len(item.ResearchMap.SimilarAccounts))
|
||||
for _, acc := range item.ResearchMap.SimilarAccounts {
|
||||
matched := append([]string(nil), acc.MatchedSource...)
|
||||
accounts = append(accounts, domusecase.SimilarAccountSummary{
|
||||
Username: acc.Username,
|
||||
Reason: acc.Reason,
|
||||
Source: acc.Source,
|
||||
MatchedSource: matched,
|
||||
Confidence: acc.Confidence,
|
||||
Status: acc.Status,
|
||||
TopicRelevance: acc.TopicRelevance,
|
||||
LastSeenAt: acc.LastSeenAt,
|
||||
ProfileURL: acc.ProfileURL,
|
||||
AuthorVerified: acc.AuthorVerified,
|
||||
FollowerCount: acc.FollowerCount,
|
||||
|
|
@ -198,13 +285,26 @@ func toSummary(item *entity.Mission) domusecase.MissionSummary {
|
|||
PostCount: acc.PostCount,
|
||||
})
|
||||
}
|
||||
samples := make([]domusecase.AudienceSampleSummary, 0, len(item.ResearchMap.AudienceSamples))
|
||||
for _, sample := range item.ResearchMap.AudienceSamples {
|
||||
samples = append(samples, domusecase.AudienceSampleSummary{
|
||||
Username: sample.Username,
|
||||
SamplePostID: sample.SamplePostID,
|
||||
SampleText: sample.SampleText,
|
||||
ReplyLikeCount: sample.ReplyLikeCount,
|
||||
Appearances: sample.Appearances,
|
||||
FirstSeenAt: sample.FirstSeenAt,
|
||||
LastSeenAt: sample.LastSeenAt,
|
||||
Status: sample.Status,
|
||||
})
|
||||
}
|
||||
return domusecase.MissionSummary{
|
||||
ID: item.ID,
|
||||
PersonaID: item.PersonaID,
|
||||
Label: item.Label,
|
||||
SeedQuery: item.SeedQuery,
|
||||
Brief: item.Brief,
|
||||
ResearchMap: toMapSummary(item.ResearchMap, tags, accounts),
|
||||
ResearchMap: toMapSummary(item.ResearchMap, tags, accounts, samples),
|
||||
SelectedTags: append([]string(nil), item.SelectedTags...),
|
||||
LastScanJobID: item.LastScanJobID,
|
||||
Status: string(item.Status),
|
||||
|
|
@ -213,19 +313,62 @@ func toSummary(item *entity.Mission) domusecase.MissionSummary {
|
|||
}
|
||||
}
|
||||
|
||||
func toMapSummary(m entity.ResearchMap, tags []domusecase.SuggestedTagSummary, accounts []domusecase.SimilarAccountSummary) domusecase.ResearchMapSummary {
|
||||
func toResearchItemSummaries(items []entity.ResearchItem) []domusecase.ResearchItemSummary {
|
||||
out := make([]domusecase.ResearchItemSummary, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, domusecase.ResearchItemSummary{
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Snippet: item.Snippet,
|
||||
Query: item.Query,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toMapSummary(m entity.ResearchMap, tags []domusecase.SuggestedTagSummary, accounts []domusecase.SimilarAccountSummary, samples []domusecase.AudienceSampleSummary) domusecase.ResearchMapSummary {
|
||||
return domusecase.ResearchMapSummary{
|
||||
AudienceSummary: m.AudienceSummary,
|
||||
ContentGoal: m.ContentGoal,
|
||||
Questions: append([]string(nil), m.Questions...),
|
||||
Pillars: append([]string(nil), m.Pillars...),
|
||||
Exclusions: append([]string(nil), m.Exclusions...),
|
||||
KnowledgeItems: append([]string(nil), m.KnowledgeItems...),
|
||||
SelectedKnowledgeItems: append([]string(nil), m.SelectedKnowledgeItems...),
|
||||
ResearchItems: toResearchItemSummaries(m.ResearchItems),
|
||||
SuggestedTags: tags,
|
||||
SimilarAccounts: accounts,
|
||||
AudienceSamples: samples,
|
||||
BenchmarkNotes: m.BenchmarkNotes,
|
||||
}
|
||||
}
|
||||
|
||||
func validateSimilarAccountStatus(raw string) (string, error) {
|
||||
status := strings.TrimSpace(raw)
|
||||
if status == "" {
|
||||
status = entity.SimilarAccountStatusRecommended
|
||||
}
|
||||
switch status {
|
||||
case entity.SimilarAccountStatusRecommended,
|
||||
entity.SimilarAccountStatusPinned,
|
||||
entity.SimilarAccountStatusExcluded,
|
||||
entity.SimilarAccountStatusPromoted:
|
||||
return status, nil
|
||||
default:
|
||||
return "", app.For(code.Facade).ResInvalidState("similar account status not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func validateAudienceSampleStatus(raw string) (string, error) {
|
||||
status := strings.TrimSpace(raw)
|
||||
switch status {
|
||||
case "", entity.AudienceSampleStatusPinned, entity.AudienceSampleStatusExcluded:
|
||||
return status, nil
|
||||
default:
|
||||
return "", app.For(code.Facade).ResInvalidState("audience sample status not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func cleanStringList(items []string) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ type QueueRepository interface {
|
|||
Enqueue(ctx context.Context, workerType, jobID string) error
|
||||
Dequeue(ctx context.Context, workerType string, timeoutSeconds int) (string, error)
|
||||
RemoveJob(ctx context.Context, workerType, jobID string) error
|
||||
RemoveOneJob(ctx context.Context, workerType, jobID string) error
|
||||
SetCancelSignal(ctx context.Context, jobID, reason string) error
|
||||
GetCancelSignal(ctx context.Context, jobID string) (bool, string, error)
|
||||
ClearCancelSignal(ctx context.Context, jobID string) error
|
||||
|
|
|
|||
|
|
@ -28,4 +28,5 @@ type RunRepository interface {
|
|||
FindPendingDue(ctx context.Context, now int64, limit int64) ([]*entity.Run, error)
|
||||
FindCancelRequestedBefore(ctx context.Context, before int64, limit int64) ([]*entity.Run, error)
|
||||
FindRunningTimedOut(ctx context.Context, now int64, limit int64) ([]*entity.Run, error)
|
||||
FindQueuedWithLock(ctx context.Context, limit int64) ([]*entity.Run, error)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue