diff --git a/haixun-backend/.run/api.pid b/haixun-backend/.run/api.pid index 565a9be..e31ea28 100644 --- a/haixun-backend/.run/api.pid +++ b/haixun-backend/.run/api.pid @@ -1 +1 @@ -37538 +65532 diff --git a/haixun-backend/.run/logs/api.log b/haixun-backend/.run/logs/api.log index d45b35f..6300388 100644 --- a/haixun-backend/.run/logs/api.log +++ b/haixun-backend/.run/logs/api.log @@ -1,196 +1,99 @@ -2026/06/24 18:00:57 job scheduler started: holder=wangxinghuadeMac-mini.local-scheduler interval=1m0s -2026/06/24 18:00:57 job worker started: id=wangxinghuadeMac-mini.local-go-worker type=go -2026/06/24 18:00:57 job reaper started: interval=30s +2026/06/25 00:47:48 job worker started: id=wangxinghuadeMacBook-Pro-204.local-go-worker type=go Starting backend backend at 0.0.0.0:8890... -{"@timestamp":"2026-06-24T18:00:58.621+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:51285 - curl/8.7.1","duration":"0.4ms","level":"info","span":"66758ec2330b8796","trace":"3a2d931d474a92d8e5ca00fb192de5bd"} -{"@timestamp":"2026-06-24T18:00:58.635+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:51286 - curl/8.7.1","duration":"0.1ms","level":"info","span":"27fed596de441796","trace":"ec2e383877e3c1fd4a821e64641b2200"} -{"@timestamp":"2026-06-24T18:01:01.870+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2011.1ms)","duration":"2011.1ms","level":"slow","span":"1a5d9a17050c6266","trace":"68f687ce8f962b6798e3a1524316eb62"} -{"@timestamp":"2026-06-24T18:01:01.870+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2011.1ms","level":"info","span":"1a5d9a17050c6266","trace":"68f687ce8f962b6798e3a1524316eb62"} -{"@timestamp":"2026-06-24T18:01:06.915+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2038.0ms)","duration":"2038.0ms","level":"slow","span":"5c15d35f84195cdc","trace":"20e4a4f831c0ee7ed70d9fc65f8ef855"} -{"@timestamp":"2026-06-24T18:01:06.915+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2038.0ms","level":"info","span":"5c15d35f84195cdc","trace":"20e4a4f831c0ee7ed70d9fc65f8ef855"} -{"@timestamp":"2026-06-24T18:01:11.991+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2072.2ms)","duration":"2072.2ms","level":"slow","span":"608044713d3ecc67","trace":"2b8eb97da66f9f3b31f0d2d2ea6e549a"} -{"@timestamp":"2026-06-24T18:01:11.991+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2072.2ms","level":"info","span":"608044713d3ecc67","trace":"2b8eb97da66f9f3b31f0d2d2ea6e549a"} -{"@timestamp":"2026-06-24T18:01:15.441+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/auth/login - 127.0.0.1:51327 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"78.9ms","level":"info","span":"abe9d77055ec37c3","trace":"ebc6a1ea7fbe267ac12abc1d6978f43e"} -{"@timestamp":"2026-06-24T18:01:15.448+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:51329 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.9ms","level":"info","span":"80ee12d4dfdb742e","trace":"a56a684c13501866cc755db08d986802"} -{"@timestamp":"2026-06-24T18:01:15.477+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:51330 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.1ms","level":"info","span":"50b3af8f508a7bad","trace":"ae3f6a699b5a833db4d16af44f9667f4"} -{"@timestamp":"2026-06-24T18:01:15.479+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:51337 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.1ms","level":"info","span":"1e7990336693a8ad","trace":"1651fa931c83db955031a8ded56793ab"} -{"@timestamp":"2026-06-24T18:01:15.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51331 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"8.1ms","level":"info","span":"81b8bdeeca0131c1","trace":"dc7d7898fe17adedb30a2ec33bec4b3f"} -{"@timestamp":"2026-06-24T18:01:15.487+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51341 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.2ms","level":"info","span":"c25e821aabb75d94","trace":"6c42b55e5d7f2f8fb758509e483ead0d"} -{"@timestamp":"2026-06-24T18:01:15.490+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:51333 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"13.2ms","level":"info","span":"4d5cf7494fde32a6","trace":"e30d243f39510550b64938af08a7eac8"} -{"@timestamp":"2026-06-24T18:01:15.491+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51332 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"14.2ms","level":"info","span":"e84231c510c6a26f","trace":"889d68e1684cc4094bebd8c1ba434035"} -{"@timestamp":"2026-06-24T18:01:15.493+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51345 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.9ms","level":"info","span":"388090d6e721ba93","trace":"af46eec0cbbc4a4a3d957d36792d34e4"} -{"@timestamp":"2026-06-24T18:01:15.494+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:51343 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.6ms","level":"info","span":"2b56151c80e9d146","trace":"d0aa214e47ff735cad38553a54726aed"} -{"@timestamp":"2026-06-24T18:01:15.495+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51347 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.9ms","level":"info","span":"1ed1d45ac7164cf6","trace":"85b0b11b05a5938109f2499c30dc1523"} -{"@timestamp":"2026-06-24T18:01:15.579+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51351 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.8ms","level":"info","span":"a3ff8b0c5d3294a1","trace":"f8460918c85682b320451d5e47308705"} -{"@timestamp":"2026-06-24T18:01:15.580+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:51353 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.0ms","level":"info","span":"ed5870a72897da97","trace":"cdc1fe38dafbbefb3528c42b4e87b57e"} -{"@timestamp":"2026-06-24T18:01:15.580+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:51355 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.0ms","level":"info","span":"3201b8da37cfb6b0","trace":"b361a8bb52373973efdb1c9ad30fe4a0"} -{"@timestamp":"2026-06-24T18:01:15.584+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/ai-settings - 127.0.0.1:51350 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"8.2ms","level":"info","span":"8f611398941e6740","trace":"99f583c6f042f69cdf8deb100b033820"} -{"@timestamp":"2026-06-24T18:01:15.593+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me/placement-settings - 127.0.0.1:51361 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.5ms","level":"info","span":"873e6db8262154b4","trace":"c301a9762581f26dba281f520cf8c4e0"} -{"@timestamp":"2026-06-24T18:01:15.593+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51363 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.5ms","level":"info","span":"fef6881c8740a588","trace":"4ad096174cf2c963846f5ba91719610c"} -{"@timestamp":"2026-06-24T18:01:15.595+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51360 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"c1d3ae59694de798","trace":"07cad47551932eddbaa03214cc7fec42"} -{"@timestamp":"2026-06-24T18:01:15.595+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/ai-settings - 127.0.0.1:51362 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.3ms","level":"info","span":"a27816c0cc868711","trace":"fb08d66c1ef2104d6c7747d0625fd996"} -{"@timestamp":"2026-06-24T18:01:15.597+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me/placement-settings - 127.0.0.1:51366 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.5ms","level":"info","span":"71e79b47ea6fa972","trace":"61745c827581f1d4d395d8203ba79eda"} -{"@timestamp":"2026-06-24T18:01:15.598+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/ai-settings - 127.0.0.1:51370 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.8ms","level":"info","span":"016963296d857c8f","trace":"4def1ea807602afc4021b0b0c5ca47ae"} -{"@timestamp":"2026-06-24T18:01:15.598+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51369 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.5ms","level":"info","span":"8a2e653657db4db4","trace":"6f8f20cabbb57d419f8ef94f2277f0f7"} -{"@timestamp":"2026-06-24T18:01:15.600+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51372 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.9ms","level":"info","span":"3ae3150351c07124","trace":"996a4ca8b2514b29b916cba9a4fa9095"} -{"@timestamp":"2026-06-24T18:01:15.603+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51374 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"f92f5c3400e085d7","trace":"27d16424d1c0f55a24de873656dfbe3d"} -{"@timestamp":"2026-06-24T18:01:17.053+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2054.2ms)","duration":"2054.2ms","level":"slow","span":"7d90b3fa8723de02","trace":"ec7d9a13c38777d2102d91ea28ee6d33"} -{"@timestamp":"2026-06-24T18:01:17.053+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2054.2ms","level":"info","span":"7d90b3fa8723de02","trace":"ec7d9a13c38777d2102d91ea28ee6d33"} -{"@timestamp":"2026-06-24T18:01:17.482+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51376 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"9b4f9a0f0f04334b","trace":"f441315e23a86c96b7221b4a28cd7053"} -{"@timestamp":"2026-06-24T18:01:19.856+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51389 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"10.8ms","level":"info","span":"aa531e49ec376ac6","trace":"50c2b540d4571b6731472237e8ed706e"} -{"@timestamp":"2026-06-24T18:01:21.852+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51391 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.7ms","level":"info","span":"c31b0004c902f5b8","trace":"fad6e87e30933561b19416284af450a8"} -{"@timestamp":"2026-06-24T18:01:22.157+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2095.1ms)","duration":"2095.1ms","level":"slow","span":"7acd3787bf9a32f4","trace":"dc23aee94472c9597141b7a2591a9fcc"} -{"@timestamp":"2026-06-24T18:01:22.157+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2095.1ms","level":"info","span":"7acd3787bf9a32f4","trace":"dc23aee94472c9597141b7a2591a9fcc"} -{"@timestamp":"2026-06-24T18:01:23.850+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51394 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.7ms","level":"info","span":"491598dfa436e1e7","trace":"490e8c67a54cd74a93ceacdaa4f75d02"} -{"@timestamp":"2026-06-24T18:01:25.848+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51396 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"5c7a42afd2298584","trace":"eb5fc4f5a2c13d90b939d80d7e256e3f"} -{"@timestamp":"2026-06-24T18:01:27.168+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2004.2ms)","duration":"2004.2ms","level":"slow","span":"2f490a8459a68dad","trace":"bd6a91641218f194c23038bff894eff2"} -{"@timestamp":"2026-06-24T18:01:27.168+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2004.2ms","level":"info","span":"2f490a8459a68dad","trace":"bd6a91641218f194c23038bff894eff2"} -{"@timestamp":"2026-06-24T18:01:27.850+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51398 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"197fde8cb14ce8aa","trace":"37468e129c2091579ba61a96154a2c49"} -{"@timestamp":"2026-06-24T18:01:29.851+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51400 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"b5d5d4bb16175af6","trace":"442eeb571ad7f026f1407a6deee97449"} -{"@timestamp":"2026-06-24T18:01:31.851+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51402 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"25a2ed6ad347a7ba","trace":"9103a15c654d0a74385a768b8012895d"} -{"@timestamp":"2026-06-24T18:01:32.260+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2085.4ms)","duration":"2085.4ms","level":"slow","span":"412e35ebdc9d3561","trace":"afcc10be8c8053cbf885dc5c5f7f2050"} -{"@timestamp":"2026-06-24T18:01:32.260+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2085.4ms","level":"info","span":"412e35ebdc9d3561","trace":"afcc10be8c8053cbf885dc5c5f7f2050"} -{"@timestamp":"2026-06-24T18:01:33.850+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51404 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.8ms","level":"info","span":"08f3d146b791a4b7","trace":"664d53b14b9d002d868847cc99d31972"} -{"@timestamp":"2026-06-24T18:01:35.851+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51406 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.0ms","level":"info","span":"3d15e35950cb1d3a","trace":"c998cdfae26a4b8da49546bc9021012c"} -{"@timestamp":"2026-06-24T18:01:37.368+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2101.8ms)","duration":"2101.8ms","level":"slow","span":"d674f928c8cbd3f6","trace":"c7024b9578dfc6e52b090777101bae3f"} -{"@timestamp":"2026-06-24T18:01:37.369+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2101.8ms","level":"info","span":"d674f928c8cbd3f6","trace":"c7024b9578dfc6e52b090777101bae3f"} -{"@timestamp":"2026-06-24T18:01:37.487+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51408 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.2ms","level":"info","span":"4ab5e0a500752b8c","trace":"668f45d3e5902fa214e2394bb76facaf"} -{"@timestamp":"2026-06-24T18:01:39.486+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51410 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.9ms","level":"info","span":"e5a61e9a53a7538c","trace":"ea8ffde0e9518909bb16f331c2d09a4d"} -{"@timestamp":"2026-06-24T18:01:41.481+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51412 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.9ms","level":"info","span":"5e7ef9b0a0f70e9a","trace":"279cc3bc4b64f91424298f136666e2f6"} -{"@timestamp":"2026-06-24T18:01:42.054+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51418 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"7.1ms","level":"info","span":"90844f526ad04747","trace":"727397dc71966b5c48a3faf18ab978f1"} -{"@timestamp":"2026-06-24T18:01:42.056+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?scope=persona&scope_id=51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51422 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.5ms","level":"info","span":"b5517aef70aed401","trace":"9acb009245c6bea40bcefb5a54183cb2"} -{"@timestamp":"2026-06-24T18:01:42.056+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51419 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"8.9ms","level":"info","span":"3dedb7cd2bf8fff8","trace":"85894ad5759e5fdab8301a8b1e430d9b"} -{"@timestamp":"2026-06-24T18:01:42.058+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-drafts - 127.0.0.1:51421 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"8.6ms","level":"info","span":"8b3fab9012740f7b","trace":"5214420b603d8f9fc458007eb6f1cf12"} -{"@timestamp":"2026-06-24T18:01:42.059+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51426 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"095bedfce7de06e4","trace":"be7bf1cf62c72ca28dd5c4c4bbdaa378"} -{"@timestamp":"2026-06-24T18:01:42.062+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51429 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"2bc74a062d798d94","trace":"1e40e08224a895428d03c26dd1d66741"} -{"@timestamp":"2026-06-24T18:01:42.062+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/viral-scan-posts - 127.0.0.1:51420 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"14.7ms","level":"info","span":"d5bbc48cf4df69ad","trace":"3e31272431290c8781cb70578e3c7ff5"} -{"@timestamp":"2026-06-24T18:01:42.063+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?scope=persona&scope_id=51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51431 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"66e68c9b7ebee609","trace":"c9419f4737fcf23df298ab5e24e0e930"} -{"@timestamp":"2026-06-24T18:01:42.064+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-drafts - 127.0.0.1:51433 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.0ms","level":"info","span":"27fda5daad16da39","trace":"377ca9a8423c4d9bae70bdac48b659dd"} -{"@timestamp":"2026-06-24T18:01:42.067+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51435 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"f35c4a33f0e4589f","trace":"ac189de2a86100b88a9a97afcf243633"} -{"@timestamp":"2026-06-24T18:01:42.069+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51437 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"e4040d61fe035213","trace":"e214280cc1d5b655c8a500e4d6c8f370"} -{"@timestamp":"2026-06-24T18:01:42.069+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/viral-scan-posts - 127.0.0.1:51438 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"7da1a6ccc3212dca","trace":"f44ff84bedd1a0e90717d077f83e2c47"} -{"@timestamp":"2026-06-24T18:01:42.420+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2046.2ms)","duration":"2046.2ms","level":"slow","span":"d665ec3871add866","trace":"a66d3bf02c3c8931ef11a0f48222f5e9"} -{"@timestamp":"2026-06-24T18:01:42.421+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2046.2ms","level":"info","span":"d665ec3871add866","trace":"a66d3bf02c3c8931ef11a0f48222f5e9"} -{"@timestamp":"2026-06-24T18:01:43.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51440 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.7ms","level":"info","span":"45f1cdbd0a755643","trace":"d749f57448cc84536697ac3e37e6a7c3"} -{"@timestamp":"2026-06-24T18:01:44.923+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51444 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"67a0892166877a50","trace":"b0d08ca067ee9a7bec52975967684546"} -{"@timestamp":"2026-06-24T18:01:44.928+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51443 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"7.4ms","level":"info","span":"0f427e161c5c4292","trace":"a85fb7a9a696836cc490abc5d6ec20db"} -{"@timestamp":"2026-06-24T18:01:44.928+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51446 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"c35d966ce4529aea","trace":"e9a8aba7e0ee8f228f71f2231a295e7d"} -{"@timestamp":"2026-06-24T18:01:44.931+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51448 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.2ms","level":"info","span":"0c96d9043e571663","trace":"d666952a3e772cff5ad61102d166782f"} -{"@timestamp":"2026-06-24T18:01:45.487+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51450 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"537f587213820860","trace":"88ee0c9e7fd7a1b4c362907859004323"} -{"@timestamp":"2026-06-24T18:01:46.584+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51456 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"75533dd166ee4460","trace":"4fde8372d7dc54c4b9089302993e5060"} -{"@timestamp":"2026-06-24T18:01:46.584+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51457 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.0ms","level":"info","span":"a2ce24e28282efb6","trace":"7447802b5890fdb0281dd18214792549"} -{"@timestamp":"2026-06-24T18:01:46.585+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/viral-scan-posts - 127.0.0.1:51458 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"923158de869a3d1e","trace":"922f2cf3ee0bb3d7f94e08cb467a65cb"} -{"@timestamp":"2026-06-24T18:01:46.585+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-drafts - 127.0.0.1:51459 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.0ms","level":"info","span":"8c455451da537957","trace":"41313b6e9b3c66db152ce52e21cae457"} -{"@timestamp":"2026-06-24T18:01:46.585+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?scope=persona&scope_id=51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51460 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.2ms","level":"info","span":"5babce44fe63846c","trace":"c7d90d5ab70e4a5a3e02322f952e2b73"} -{"@timestamp":"2026-06-24T18:01:46.591+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51466 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.2ms","level":"info","span":"7a48b6d3f34131bc","trace":"af1b7d123ee0495eaede919dba6736c6"} -{"@timestamp":"2026-06-24T18:01:46.592+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51467 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.3ms","level":"info","span":"7377b393dd8498de","trace":"c299400829ebbe749017335e1e635461"} -{"@timestamp":"2026-06-24T18:01:46.593+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-drafts - 127.0.0.1:51469 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"ba3b750a61d618a3","trace":"8d50af450c6a82682270b73bb3278eb7"} -{"@timestamp":"2026-06-24T18:01:46.593+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?scope=persona&scope_id=51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51470 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.9ms","level":"info","span":"6391d93adb973f11","trace":"58f9420bbebcbcc3262ba87766c42c36"} -{"@timestamp":"2026-06-24T18:01:46.593+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/viral-scan-posts - 127.0.0.1:51468 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.0ms","level":"info","span":"9b7e9b10e0ec31d4","trace":"ac8bdf23fce6484d5085411f0eef8bb2"} -{"@timestamp":"2026-06-24T18:01:46.594+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51472 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.2ms","level":"info","span":"ab87ea3a013577e8","trace":"c2990ad408302747f5f028f7916406b2"} -{"@timestamp":"2026-06-24T18:01:46.596+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51474 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.3ms","level":"info","span":"20fdcf5c41507134","trace":"15f89b4dbaf9958404026e28657d29a9"} -{"@timestamp":"2026-06-24T18:01:47.356+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51478 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"930489c6a1aebc56","trace":"73aab7f90ba4609643fe2d345d5243de"} -{"@timestamp":"2026-06-24T18:01:47.356+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51477 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"1ea56b1795f49d04","trace":"3bce180f140870493a212f5c7954cd74"} -{"@timestamp":"2026-06-24T18:01:47.363+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51480 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.5ms","level":"info","span":"bcdd8e8f725e7758","trace":"b376801460a80992bcccf75da0a48bd7"} -{"@timestamp":"2026-06-24T18:01:47.367+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51482 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"105a1010486ee362","trace":"718207419980f40f8a9516c1d6ef0976"} -{"@timestamp":"2026-06-24T18:01:47.481+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2056.4ms)","duration":"2056.4ms","level":"slow","span":"192bfc094baa98f2","trace":"c34feae650189910605449bc4d084348"} -{"@timestamp":"2026-06-24T18:01:47.481+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2056.4ms","level":"info","span":"192bfc094baa98f2","trace":"c34feae650189910605449bc4d084348"} -{"@timestamp":"2026-06-24T18:01:47.484+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51484 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.2ms","level":"info","span":"3bd095358af9d7e8","trace":"0c7c942b85e67d62b185dcca52069cc2"} -{"@timestamp":"2026-06-24T18:01:49.484+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51486 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"5477e967e999ef97","trace":"35df9d952ce5a6c077c0c47a4e9c9fe0"} -{"@timestamp":"2026-06-24T18:01:51.482+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51488 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"bdcf44a4a23c215d","trace":"369cafba9cfecc14e6e929f7fe5f63dc"} -{"@timestamp":"2026-06-24T18:01:52.555+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2068.2ms)","duration":"2068.2ms","level":"slow","span":"9851f77d270375b9","trace":"557d40d6c5e5e7d49978a614fc8c22cf"} -{"@timestamp":"2026-06-24T18:01:52.556+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2068.2ms","level":"info","span":"9851f77d270375b9","trace":"557d40d6c5e5e7d49978a614fc8c22cf"} -{"@timestamp":"2026-06-24T18:01:53.484+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51490 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"3efeef93ee83b5f7","trace":"f9aa95bf5227b60ccff5cf46d5afcd57"} -{"@timestamp":"2026-06-24T18:01:55.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51492 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"14fc3ee8c9d972c1","trace":"57c5dcdcb596d5d817996ab47e10da42"} -{"@timestamp":"2026-06-24T18:01:55.854+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51496 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"f9f225f106f702e9","trace":"b8da755041c67e7b391bad126a40146c"} -{"@timestamp":"2026-06-24T18:01:55.856+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/8efad4db-346f-420c-949f-f5cba9d4b519 - 127.0.0.1:51495 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"27d98e8b9be5a927","trace":"fb5c5a428e7f724ead4885a47f59faff"} -{"@timestamp":"2026-06-24T18:01:55.861+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/8efad4db-346f-420c-949f-f5cba9d4b519 - 127.0.0.1:51500 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.1ms","level":"info","span":"78b6c3efbeb476af","trace":"f916add6ee99c8e1206c702800200eaa"} -{"@timestamp":"2026-06-24T18:01:55.863+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51499 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"b79a08ee527436d7","trace":"df716864503562183566016a0c394566"} -{"@timestamp":"2026-06-24T18:01:57.486+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51502 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"b6b6a526c3c0ffdb","trace":"ebec3ad469fdd656d0a5128b64c3f516"} -{"@timestamp":"2026-06-24T18:01:57.622+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2062.7ms)","duration":"2062.7ms","level":"slow","span":"8ea79b43ea2109f0","trace":"c66ad0a837396842ff78b75e220b739d"} -{"@timestamp":"2026-06-24T18:01:57.622+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2062.7ms","level":"info","span":"8ea79b43ea2109f0","trace":"c66ad0a837396842ff78b75e220b739d"} -{"@timestamp":"2026-06-24T18:01:57.822+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.2Mi, TotalAlloc=10.2Mi, Sys=23.1Mi, NumGC=7","level":"stat"} -{"@timestamp":"2026-06-24T18:01:57.887+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 95, pass: 95, drop: 0","level":"stat"} -{"@timestamp":"2026-06-24T18:01:58.622+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 1.6/s, drops: 0, avg time: 263.6ms, med: 3.0ms, 90th: 2046.1ms, 99th: 2101.7ms, 99.9th: 2101.7ms","level":"stat"} -{"@timestamp":"2026-06-24T18:01:59.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51504 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"032d6eb34eb1fc07","trace":"6a8639526bca27c80c79951fd568c58e"} -{"@timestamp":"2026-06-24T18:02:01.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51506 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"27f14f37fdf7b84c","trace":"62acf1119fbfaa9576214bdff025e969"} -{"@timestamp":"2026-06-24T18:02:02.661+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2034.6ms)","duration":"2034.6ms","level":"slow","span":"522e89ae272a62a3","trace":"b50fcc89be0c4d6752cbf071e22a55a1"} -{"@timestamp":"2026-06-24T18:02:02.661+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2034.6ms","level":"info","span":"522e89ae272a62a3","trace":"b50fcc89be0c4d6752cbf071e22a55a1"} -{"@timestamp":"2026-06-24T18:02:03.496+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51508 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"13.1ms","level":"info","span":"ad408ff1dd06b7b6","trace":"b5726c1921de4786a04b169fe2911d4c"} -{"@timestamp":"2026-06-24T18:02:05.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51510 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"d1071b2bee67f53d","trace":"5b5881893368345ddcec5ac2a3ecb974"} -{"@timestamp":"2026-06-24T18:02:07.482+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51512 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"26c0652f877db069","trace":"cf4a822663add34c07300fb4b00e84d5"} -{"@timestamp":"2026-06-24T18:02:07.520+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51516 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"b38fa888b574dfee","trace":"580dd977656d1ecd954fcf409d82fd94"} -{"@timestamp":"2026-06-24T18:02:07.520+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51515 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"3dffbc59cc70684d","trace":"2d95e80af9b5a333105d6fb490a2d7ba"} -{"@timestamp":"2026-06-24T18:02:07.527+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51520 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.2ms","level":"info","span":"1d14fe2ac81f3465","trace":"26d446e9b1521adf250a0f7c4302424d"} -{"@timestamp":"2026-06-24T18:02:07.527+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51519 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"52c24916facfbad1","trace":"b0316cd11df666377395628badc387ce"} -{"@timestamp":"2026-06-24T18:02:07.531+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51523 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.3ms","level":"info","span":"80dbf22b371a0485","trace":"b4e2fca17a7802da00660271b8aac675"} -{"@timestamp":"2026-06-24T18:02:07.533+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/8efad4db-346f-420c-949f-f5cba9d4b519/scan-posts?recent_7d=true&product_fit_min=70&limit=100 - 127.0.0.1:51525 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"ee886faf338ce874","trace":"764ce1beb86c34f32a287cd6b27b8024"} -{"@timestamp":"2026-06-24T18:02:07.534+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51526 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"485d69797f435ea9","trace":"fc8888831110219dde434244dc320b88"} -{"@timestamp":"2026-06-24T18:02:07.721+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2055.3ms)","duration":"2055.3ms","level":"slow","span":"e0e7ffcf845dd0e4","trace":"97da1f48b714f311c56ca0534d7c76db"} -{"@timestamp":"2026-06-24T18:02:07.721+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2055.3ms","level":"info","span":"e0e7ffcf845dd0e4","trace":"97da1f48b714f311c56ca0534d7c76db"} -{"@timestamp":"2026-06-24T18:02:08.550+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51529 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"73f0f827e526bf13","trace":"8c7013d774f5cc6b25e2204b2338f945"} -{"@timestamp":"2026-06-24T18:02:08.550+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51530 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.0ms","level":"info","span":"076a5d2ae12867f7","trace":"f46acb7124202216e677912a28c81392"} -{"@timestamp":"2026-06-24T18:02:08.553+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51532 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"0db963cf68d3fc13","trace":"2ae2a87d73c9fd9edc55d8165731cdc2"} -{"@timestamp":"2026-06-24T18:02:08.556+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51534 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"b615c4468083a6b6","trace":"51db4271c4d4ddab31cb7a7a89dd32c9"} -{"@timestamp":"2026-06-24T18:02:09.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51538 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.3ms","level":"info","span":"5e8bf7af6ce67f72","trace":"a14309d987f014942de4e09829911f13"} -{"@timestamp":"2026-06-24T18:02:10.996+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51542 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.9ms","level":"info","span":"a68c46cea6ec9ea8","trace":"ce46f9d3dd709c697e473b61b543984a"} -{"@timestamp":"2026-06-24T18:02:10.996+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51541 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.1ms","level":"info","span":"2f0141f8868a6543","trace":"d5e9f910c3d776ba768e84aa9dbc3986"} -{"@timestamp":"2026-06-24T18:02:11.002+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51545 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"b7ef63ff8ec85463","trace":"6fe009be8693540698644e590caddd6b"} -{"@timestamp":"2026-06-24T18:02:11.002+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51546 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"30689dd3726e25cc","trace":"14f1b82c057ae8bd49cc232e4c74e2d0"} -{"@timestamp":"2026-06-24T18:02:11.006+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51551 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"6ee9bb99beb4770c","trace":"9ecd60120939d580abdd8efc0195f63c"} -{"@timestamp":"2026-06-24T18:02:11.006+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/8efad4db-346f-420c-949f-f5cba9d4b519/scan-posts?recent_7d=true&product_fit_min=70&limit=100 - 127.0.0.1:51549 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"68c27c87e78f7564","trace":"9156641b4b33b2b12b5d925435a9c9c1"} -{"@timestamp":"2026-06-24T18:02:11.007+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51552 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.2ms","level":"info","span":"639f5d7abc24536d","trace":"1e7ba74fd368ab370bf40da4ecc2bcde"} -{"@timestamp":"2026-06-24T18:02:11.482+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51554 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"5424033348cb5468","trace":"0e8fc20cb8b7ed3efb90c1657977951d"} -{"@timestamp":"2026-06-24T18:02:11.802+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51559 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.1ms","level":"info","span":"d36967f6403faf95","trace":"d1ad65e1341cab44e926167cd81dbaa6"} -{"@timestamp":"2026-06-24T18:02:11.804+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-drafts - 127.0.0.1:51563 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"deb84fedf7c68120","trace":"ba9046a1399a99a0c9e0da13b9bb3e2e"} -{"@timestamp":"2026-06-24T18:02:11.804+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51561 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"d6ea95c1925b5398","trace":"0c3d3a4b32c1ee56b8ebde2b1895cae8"} -{"@timestamp":"2026-06-24T18:02:11.804+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?scope=persona&scope_id=51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51564 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"a184e137cdb637b2","trace":"4fde40e15bc9f0f7bd1f820d650b2265"} -{"@timestamp":"2026-06-24T18:02:11.805+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/viral-scan-posts - 127.0.0.1:51562 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.9ms","level":"info","span":"11ddfaad7a70b902","trace":"8d6890688fba90bd23f1921ef5e2f99d"} -{"@timestamp":"2026-06-24T18:02:11.806+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51566 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"eec5d6f0ee4cbdaf","trace":"7839441b3d905084d86db714f664ecae"} -{"@timestamp":"2026-06-24T18:02:11.810+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51572 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"9c5ce5504a8e0633","trace":"3c4e09762ecf21219b23bb5ac844fc62"} -{"@timestamp":"2026-06-24T18:02:11.812+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/copy-drafts - 127.0.0.1:51570 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.8ms","level":"info","span":"b8f4cc0ada706475","trace":"300b9ee0628c439ed8a62f14c78114b8"} -{"@timestamp":"2026-06-24T18:02:11.812+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51577 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"b4cfa5ecc000fb87","trace":"af4bd23abb60532ff66af3dcf45ae2bf"} -{"@timestamp":"2026-06-24T18:02:11.813+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?scope=persona&scope_id=51f4d954-b152-4127-bfb9-e47bde06d1f8 - 127.0.0.1:51574 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.7ms","level":"info","span":"adbb022d1a338efb","trace":"f9d285da8275034b482549a11417087b"} -{"@timestamp":"2026-06-24T18:02:11.813+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas/51f4d954-b152-4127-bfb9-e47bde06d1f8/viral-scan-posts - 127.0.0.1:51575 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"e9e8476b9629616d","trace":"76e80318936e3a7e5a617303f23a47f7"} -{"@timestamp":"2026-06-24T18:02:11.814+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51578 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.2ms","level":"info","span":"8ef25a1304c8a7de","trace":"1bd10e04b0e289f445f4826955e4a271"} -{"@timestamp":"2026-06-24T18:02:12.794+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2069.7ms)","duration":"2069.7ms","level":"slow","span":"ca2387bd7292cb45","trace":"a056f2aaf41bba04f8f8aa789ab637c8"} -{"@timestamp":"2026-06-24T18:02:12.795+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2069.7ms","level":"info","span":"ca2387bd7292cb45","trace":"a056f2aaf41bba04f8f8aa789ab637c8"} -{"@timestamp":"2026-06-24T18:02:12.814+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51581 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.5ms","level":"info","span":"ca2619ed85f15f9b","trace":"b5d9858077e4c2d9130b2a9613c76f85"} -{"@timestamp":"2026-06-24T18:02:12.814+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51582 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"e387465ced2772b6","trace":"d9e16e9d164005428f819c6de84d284a"} -{"@timestamp":"2026-06-24T18:02:12.818+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51586 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"f6ff6712e18cd0e0","trace":"414d05590637288de8d505a2ce341b93"} -{"@timestamp":"2026-06-24T18:02:12.818+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51585 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.0ms","level":"info","span":"c730ae07802da10e","trace":"99e77fd2fe52c3731c63cbd2d567fddd"} -{"@timestamp":"2026-06-24T18:02:12.821+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51590 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.1ms","level":"info","span":"90168666f65be7d7","trace":"860cc0fd9a39acf2ca960ee6d7f5dcc9"} -{"@timestamp":"2026-06-24T18:02:12.821+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/8efad4db-346f-420c-949f-f5cba9d4b519/scan-posts?recent_7d=true&product_fit_min=70&limit=100 - 127.0.0.1:51589 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.7ms","level":"info","span":"24b7f332cc25c343","trace":"55e3f5c3b3d6d7b8064e7abe3942c305"} -{"@timestamp":"2026-06-24T18:02:12.822+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51592 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.2ms","level":"info","span":"abc319fcf81b18ab","trace":"3d6b22cd15662fccfb52c78b752ca4e6"} -{"@timestamp":"2026-06-24T18:02:13.484+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51594 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"79ab7347c668cc8a","trace":"95f9b57a8c13ab5c2495ac411858ec5e"} -{"@timestamp":"2026-06-24T18:02:13.535+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51597 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"7a1882bac7aa5c81","trace":"5c1a1357437fc9e4435a037131d2225a"} -{"@timestamp":"2026-06-24T18:02:13.535+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51598 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"0e9b79fb30b80b97","trace":"5fd668594b1e5701aced89a76758b7b9"} -{"@timestamp":"2026-06-24T18:02:13.540+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51600 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"abdfc58834c62b87","trace":"beefb9e81dc3ec7930ac2516be0f784d"} -{"@timestamp":"2026-06-24T18:02:13.541+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51602 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"53b17b18ee86c267","trace":"05e4627774fea0fcae1949be99d74eec"} -{"@timestamp":"2026-06-24T18:02:15.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51604 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"444da1fa31c5d966","trace":"cff01baf57613b9e03e8fffa16f03647"} -{"@timestamp":"2026-06-24T18:02:17.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51606 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"c0edc4310656d11d","trace":"bc3491e264524ba5a1219e5c57a22045"} -{"@timestamp":"2026-06-24T18:02:17.855+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2057.0ms)","duration":"2057.0ms","level":"slow","span":"a5d91dc4c16dd950","trace":"ffcd9497d4f5bc6264de662ee7dd1585"} -{"@timestamp":"2026-06-24T18:02:17.855+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2057.0ms","level":"info","span":"a5d91dc4c16dd950","trace":"ffcd9497d4f5bc6264de662ee7dd1585"} -{"@timestamp":"2026-06-24T18:02:19.222+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51612 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.0ms","level":"info","span":"445efda782dc5994","trace":"87d032c52d6c084c2260c56c0d04aba2"} -{"@timestamp":"2026-06-24T18:02:19.222+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:51613 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.0ms","level":"info","span":"14138bd4de8b64fa","trace":"f9ad885e58d26f81cbb0b68f0e9779d3"} -{"@timestamp":"2026-06-24T18:02:19.226+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:51615 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"ea0f5a1196aaa4b1","trace":"3fc096b6dd609725c25af9e921809948"} -{"@timestamp":"2026-06-24T18:02:19.230+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/bf9e27eb-d529-4ef5-be11-20870194a4b8/connection - 127.0.0.1:51617 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"6f833a49f104d200","trace":"1634f761bdf1f50564524455036d94d2"} -{"@timestamp":"2026-06-24T18:02:19.235+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=20&scope=brand&scope_id=8efad4db-346f-420c-949f-f5cba9d4b519 - 127.0.0.1:51621 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.2ms","level":"info","span":"4fbc54b280c2b67d","trace":"085e2b43fa7140db64281a80c15ba1ac"} -{"@timestamp":"2026-06-24T18:02:19.242+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/8efad4db-346f-420c-949f-f5cba9d4b519/knowledge-graph - 127.0.0.1:51620 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"9.1ms","level":"info","span":"fc03b89fda085a4b","trace":"bc3359c1bb2c8c6a5baacfde665171fe"} -{"@timestamp":"2026-06-24T18:02:19.274+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/8efad4db-346f-420c-949f-f5cba9d4b519/scan-schedule - 127.0.0.1:51624 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.3ms","level":"info","span":"cccf81e4e1f0f5e2","trace":"9db66e136e22f47ddbbce9a87e4d108e"} -{"@timestamp":"2026-06-24T18:02:19.280+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/8efad4db-346f-420c-949f-f5cba9d4b519/scan-schedule - 127.0.0.1:51626 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"14d3d90caeee991a","trace":"4e5e4e6008109e08c03d0739f2d37b40"} -{"@timestamp":"2026-06-24T18:02:19.484+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51629 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.6ms","level":"info","span":"13b01cafaaf1bebe","trace":"6682d51c70a94b9c217ef04d2f93005f"} -{"@timestamp":"2026-06-24T18:02:21.486+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51631 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.3ms","level":"info","span":"173f763a07e55cea","trace":"ae997603995e2aaca4ebf58ded8386b0"} -{"@timestamp":"2026-06-24T18:02:22.939+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2079.8ms)","duration":"2079.8ms","level":"slow","span":"ce92a0edfaabecb3","trace":"b677ea931f224bb54f2f3dc898dbf14b"} -{"@timestamp":"2026-06-24T18:02:22.939+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2079.8ms","level":"info","span":"ce92a0edfaabecb3","trace":"b677ea931f224bb54f2f3dc898dbf14b"} -{"@timestamp":"2026-06-24T18:02:23.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51633 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"fc782c66c1a65774","trace":"3f0ce8584b9cdf7baf8711dcae2d9b0c"} -{"@timestamp":"2026-06-24T18:02:25.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51635 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"7b30cc3f07b9ff9c","trace":"a022a6bd641713266d320845e4d80d72"} -{"@timestamp":"2026-06-24T18:02:27.483+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51639 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"669b9e2202b98e48","trace":"7fe32ebbcd35915df068a83bdfe36d01"} -{"@timestamp":"2026-06-24T18:02:28.011+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2069.2ms)","duration":"2069.2ms","level":"slow","span":"06bcf857f29d109c","trace":"ab7b4da2e6b7002f9356ddeeea1194f9"} -{"@timestamp":"2026-06-24T18:02:28.012+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2069.2ms","level":"info","span":"06bcf857f29d109c","trace":"ab7b4da2e6b7002f9356ddeeea1194f9"} -{"@timestamp":"2026-06-24T18:02:29.485+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51641 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.5ms","level":"info","span":"ca0ed72bafb925b1","trace":"9b9b063c7a0db1740a17e669facb45f1"} -{"@timestamp":"2026-06-24T18:02:31.849+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51643 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"b259223ebefafa65","trace":"9c355051b02470a2bdbb0d3ea607c760"} -{"@timestamp":"2026-06-24T18:02:33.085+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node - slowcall(2068.8ms)","duration":"2068.8ms","level":"slow","span":"560c1d319838a9be","trace":"ba02ce797381c178a4159efb52b14268"} -{"@timestamp":"2026-06-24T18:02:33.086+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:51287 - node","duration":"2068.8ms","level":"info","span":"560c1d319838a9be","trace":"ba02ce797381c178a4159efb52b14268"} -{"@timestamp":"2026-06-24T18:02:33.845+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51645 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.1ms","level":"info","span":"091dbfa359958b1e","trace":"0591a2507d5ca3faf30ff9240b80c095"} -{"@timestamp":"2026-06-24T18:02:35.849+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:51647 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"713201d319a61733","trace":"1e772fb7ed19017314c532c34b7836f1"} +2026/06/25 00:47:48 job scheduler started: holder=wangxinghuadeMacBook-Pro-204.local-scheduler interval=1m0s +2026/06/25 00:47:48 job reaper started: interval=30s +{"@timestamp":"2026-06-25T00:47:48.840+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:63539 - curl/8.7.1","duration":"0.1ms","level":"info","span":"30db88725ec4ea20","trace":"2a18a8411865f2d97b5dc95cff93f1d7"} +{"@timestamp":"2026-06-25T00:47:48.852+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:63540 - curl/8.7.1","duration":"0.1ms","level":"info","span":"5483239b5e0f448d","trace":"163cbd48f6cdf1263f4130ffac2fe8b8"} +{"@timestamp":"2026-06-25T00:47:51.926+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2103.3ms)","duration":"2103.3ms","level":"slow","span":"2e3ae71bc157463c","trace":"0a7ce824a22353e91ee046ab2fa9aa41"} +{"@timestamp":"2026-06-25T00:47:51.927+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2103.3ms","level":"info","span":"2e3ae71bc157463c","trace":"0a7ce824a22353e91ee046ab2fa9aa41"} +{"@timestamp":"2026-06-25T00:47:57.014+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2079.9ms)","duration":"2079.9ms","level":"slow","span":"0f7edd0bc4ddf12c","trace":"987aff7d7cf6a75e98aae420003dd58f"} +{"@timestamp":"2026-06-25T00:47:57.014+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2079.9ms","level":"info","span":"0f7edd0bc4ddf12c","trace":"987aff7d7cf6a75e98aae420003dd58f"} +{"@timestamp":"2026-06-25T00:48:02.071+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2051.8ms)","duration":"2051.8ms","level":"slow","span":"fe38a2fa5bd4713d","trace":"cd3832ea128351d1cc8a8f8ff35c90db"} +{"@timestamp":"2026-06-25T00:48:02.071+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2051.8ms","level":"info","span":"fe38a2fa5bd4713d","trace":"cd3832ea128351d1cc8a8f8ff35c90db"} +{"@timestamp":"2026-06-25T00:48:02.807+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 401 - POST /api/v1/auth/login - 127.0.0.1:63578 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"80.7ms","level":"info","span":"6479b1bb7a1b113b","trace":"37e0e4b127f0822eb215b4541410d7e6"} +{"@timestamp":"2026-06-25T00:48:06.327+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/auth/login - 127.0.0.1:63586 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"85.9ms","level":"info","span":"e711f94d71b8fdad","trace":"c1baccade70a63d621ab22000c918584"} +{"@timestamp":"2026-06-25T00:48:06.337+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me - 127.0.0.1:63589 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.1ms","level":"info","span":"495ff432ba2996ed","trace":"cafa649da9c474dd496466ef06df561f"} +{"@timestamp":"2026-06-25T00:48:06.362+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:63594 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.1ms","level":"info","span":"3e9e863832000764","trace":"389c91f582ed627ec4ef3bc115eb4044"} +{"@timestamp":"2026-06-25T00:48:06.363+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:63601 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.0ms","level":"info","span":"bd20b946130476b9","trace":"0c148de74e9b9d1ae4a60ab03d45defd"} +{"@timestamp":"2026-06-25T00:48:06.368+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63595 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"6.1ms","level":"info","span":"af869141040c3e85","trace":"41460352789c5af3e412f2891ae3d260"} +{"@timestamp":"2026-06-25T00:48:06.372+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63596 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"10.0ms","level":"info","span":"ae1365fd5b281cd0","trace":"4928f54339834d2671cb6abc7606cb4a"} +{"@timestamp":"2026-06-25T00:48:06.373+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63606 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"433d87934f050e7b","trace":"b13dadf1a26adc7ae049f714f7ea98d4"} +{"@timestamp":"2026-06-25T00:48:06.375+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63608 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.9ms","level":"info","span":"49ee3b3a8693533a","trace":"5bdb0d04245082ec9383e0d54316b20a"} +{"@timestamp":"2026-06-25T00:48:06.375+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:63597 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"12.4ms","level":"info","span":"d80c8120cc13ba58","trace":"decc2bbd8e5decf59d67164ffb9aabd9"} +{"@timestamp":"2026-06-25T00:48:06.377+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63611 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.8ms","level":"info","span":"5d5d9165c3e0dc59","trace":"6e842713ac4933e57bc1f91e8e3e4fc7"} +{"@timestamp":"2026-06-25T00:48:06.378+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts - 127.0.0.1:63612 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.2ms","level":"info","span":"d3805d1b49947ee6","trace":"a4629c116162b87b7052f13e7023af1d"} +{"@timestamp":"2026-06-25T00:48:06.468+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63620 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"146375fbb9230e43","trace":"5fe84e7e0653e6c6ac398f91feeed1e7"} +{"@timestamp":"2026-06-25T00:48:06.471+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:63621 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.1ms","level":"info","span":"6b5505203255e3a9","trace":"2a7dbca27aa3a7bc773529559d6c4a69"} +{"@timestamp":"2026-06-25T00:48:06.472+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/edc2117d-e76e-4623-a4f8-d5edee8490ab/ai-settings - 127.0.0.1:63619 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"7.4ms","level":"info","span":"d3c76d1de73ed970","trace":"129c7bd27a80bb1d5dabf97601846232"} +{"@timestamp":"2026-06-25T00:48:06.473+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/health - 127.0.0.1:63622 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.0ms","level":"info","span":"2f38e8e8d281a91e","trace":"66f52d1e65f5ef5724bb072705bb0b33"} +{"@timestamp":"2026-06-25T00:48:06.487+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me/placement-settings - 127.0.0.1:63626 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.6ms","level":"info","span":"c9f96175ad32f84c","trace":"6386b6d9b360dc8b3c96364e2f790f8c"} +{"@timestamp":"2026-06-25T00:48:06.488+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/edc2117d-e76e-4623-a4f8-d5edee8490ab/ai-settings - 127.0.0.1:63627 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.2ms","level":"info","span":"5a0ccba40af8843d","trace":"cb2f1de37abdad52efd03bf60f18ed2b"} +{"@timestamp":"2026-06-25T00:48:06.488+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63628 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"32d1f3e59c3fc44e","trace":"c82b128eb5b2ec7e30bd0a3dcb821815"} +{"@timestamp":"2026-06-25T00:48:06.488+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/edc2117d-e76e-4623-a4f8-d5edee8490ab/connection - 127.0.0.1:63625 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.0ms","level":"info","span":"6c053b7e4c631c93","trace":"357f853e3986332fdeb1b8e0169f8cc1"} +{"@timestamp":"2026-06-25T00:48:06.489+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/members/me/placement-settings - 127.0.0.1:63631 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"0.7ms","level":"info","span":"3e7fbb1bf230fb08","trace":"bc760da4baf461706d8e63ebe0aa8142"} +{"@timestamp":"2026-06-25T00:48:06.490+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/edc2117d-e76e-4623-a4f8-d5edee8490ab/ai-settings - 127.0.0.1:63634 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"77120091c04869a9","trace":"6aea9e3725bf2dfdfa49dc77154b2304"} +{"@timestamp":"2026-06-25T00:48:06.491+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/edc2117d-e76e-4623-a4f8-d5edee8490ab/connection - 127.0.0.1:63635 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.0ms","level":"info","span":"a8d83d8b3103739a","trace":"a0f314918dee83be11f2e9aadb0927a0"} +{"@timestamp":"2026-06-25T00:48:06.494+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/edc2117d-e76e-4623-a4f8-d5edee8490ab/connection - 127.0.0.1:63637 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"b7671cc812b420e2","trace":"010c715a4f8bb1cf1bbe6f9ac74a08f3"} +{"@timestamp":"2026-06-25T00:48:06.497+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/edc2117d-e76e-4623-a4f8-d5edee8490ab/connection - 127.0.0.1:63639 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.4ms","level":"info","span":"db2e95937c1297b1","trace":"b0e9b77f354da3d26ba08d3a12121325"} +{"@timestamp":"2026-06-25T00:48:07.153+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2076.9ms)","duration":"2076.9ms","level":"slow","span":"9a6d678edb444f52","trace":"2ff058f80e3e290cfcd083faf1a7503b"} +{"@timestamp":"2026-06-25T00:48:07.153+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2076.9ms","level":"info","span":"9a6d678edb444f52","trace":"2ff058f80e3e290cfcd083faf1a7503b"} +{"@timestamp":"2026-06-25T00:48:08.368+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63646 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"28789b029ff027f8","trace":"36db8dff924af26f6a98907e020d13b4"} +{"@timestamp":"2026-06-25T00:48:10.370+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63647 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"5.1ms","level":"info","span":"4866b5284c362187","trace":"ebee43defd1fd6bede29cd9bd64d4a11"} +{"@timestamp":"2026-06-25T00:48:12.219+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2058.8ms)","duration":"2058.8ms","level":"slow","span":"34fc39348e1d8e6e","trace":"9c81abee1ddb28a3edcd5377d9aeb6a8"} +{"@timestamp":"2026-06-25T00:48:12.219+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2058.8ms","level":"info","span":"34fc39348e1d8e6e","trace":"9c81abee1ddb28a3edcd5377d9aeb6a8"} +{"@timestamp":"2026-06-25T00:48:12.369+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63649 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.7ms","level":"info","span":"e118d3437e3f9b8e","trace":"b5acb02d4608ded6c2ca8644528b3e43"} +{"@timestamp":"2026-06-25T00:48:13.642+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63655 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"7.9ms","level":"info","span":"7822e986d6e3f5d7","trace":"31fb13e04c613777dbb6f8109a26f5f5"} +{"@timestamp":"2026-06-25T00:48:13.643+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:63654 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"9.0ms","level":"info","span":"ff0edee4436b7572","trace":"746455cb776ca325b693d3535c83d763"} +{"@timestamp":"2026-06-25T00:48:13.648+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/ - 127.0.0.1:63659 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"d954c795fd46a969","trace":"5df82aa362ca3ad69adc3efa19403476"} +{"@timestamp":"2026-06-25T00:48:13.648+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/ - 127.0.0.1:63653 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"14.9ms","level":"info","span":"464545477f2cb7d0","trace":"1c7531e523992bac7c13dc2809b66fa8"} +{"@timestamp":"2026-06-25T00:48:13.648+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/edc2117d-e76e-4623-a4f8-d5edee8490ab/connection - 127.0.0.1:63658 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.8ms","level":"info","span":"548097ee742d09b7","trace":"65eb1f29402ce0f2dd5f9ff4efc511c8"} +{"@timestamp":"2026-06-25T00:48:13.654+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/ - 127.0.0.1:63661 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.6ms","level":"info","span":"dbd90c923734ac8f","trace":"dc9f520fc13a3168d2aa5fb958123089"} +{"@timestamp":"2026-06-25T00:48:14.369+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63663 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"81d796495dd72c7c","trace":"c0fd131218613f9561e92b47c5c155cf"} +{"@timestamp":"2026-06-25T00:48:15.251+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/personas - 127.0.0.1:63670 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.5ms","level":"info","span":"2112330518348583","trace":"93bf0a193efed3677f1f26c9a9c467e9"} +{"@timestamp":"2026-06-25T00:48:15.252+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=20&scope=placement_topic&scope_id=c6516f8a-c330-4360-8f0f-a5eaa5cfb573 - 127.0.0.1:63669 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.5ms","level":"info","span":"3cd6ef66ef72006e","trace":"5ce23d70e572f0a3cf06d03597a72866"} +{"@timestamp":"2026-06-25T00:48:15.256+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/c6516f8a-c330-4360-8f0f-a5eaa5cfb573 - 127.0.0.1:63668 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"7.8ms","level":"info","span":"5720e1b6b5ebbce4","trace":"632243f369cd6208cf2766e0f4c4dce1"} +{"@timestamp":"2026-06-25T00:48:15.258+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=20&scope=placement_topic&scope_id=c6516f8a-c330-4360-8f0f-a5eaa5cfb573 - 127.0.0.1:63672 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.8ms","level":"info","span":"fb9c227e9e0701ad","trace":"f4bae8ac617aaf49259f65ccb4fef564"} +{"@timestamp":"2026-06-25T00:48:15.259+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/threads-accounts/edc2117d-e76e-4623-a4f8-d5edee8490ab/connection - 127.0.0.1:63673 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.9ms","level":"info","span":"0c03a02a10812b95","trace":"69cb7a34621e49f105e96785050dba5e"} +{"@timestamp":"2026-06-25T00:48:15.261+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/cd608c78-342d-4c83-85d6-53556ee985ff - 127.0.0.1:63677 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.2ms","level":"info","span":"5a8220dbb868e2ff","trace":"f56fcc1526903aa707ea83f53661e412"} +{"@timestamp":"2026-06-25T00:48:15.261+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/c6516f8a-c330-4360-8f0f-a5eaa5cfb573 - 127.0.0.1:63675 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.3ms","level":"info","span":"fc66c6d626b8939d","trace":"8418b7fd041d0ec78798334f0716043c"} +{"@timestamp":"2026-06-25T00:48:15.267+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/brands/cd608c78-342d-4c83-85d6-53556ee985ff - 127.0.0.1:63681 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"1.4ms","level":"info","span":"9760c858f60bc463","trace":"5ee3bc2e076c02c9b0cb2a137fbc1753"} +{"@timestamp":"2026-06-25T00:48:15.272+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/c6516f8a-c330-4360-8f0f-a5eaa5cfb573/knowledge-graph - 127.0.0.1:63680 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"7.1ms","level":"info","span":"41fb3883b9200b35","trace":"fc36a5bc01305e56cb110c4c7aac8810"} +{"@timestamp":"2026-06-25T00:48:15.277+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/placement/topics/c6516f8a-c330-4360-8f0f-a5eaa5cfb573/knowledge-graph - 127.0.0.1:63683 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.9ms","level":"info","span":"3eeaa9105d8cd770","trace":"c8239df786deace70b92523eaab1df70"} +{"@timestamp":"2026-06-25T00:48:16.367+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63686 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.0ms","level":"info","span":"972ada16b81e27a9","trace":"eb3857471cab1bab57b45155a4eb67f4"} +{"@timestamp":"2026-06-25T00:48:17.301+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2075.2ms)","duration":"2075.2ms","level":"slow","span":"68899fafd0deb5e2","trace":"de08f822acb4f5cd3f15f5acc71d6881"} +{"@timestamp":"2026-06-25T00:48:17.301+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2075.2ms","level":"info","span":"68899fafd0deb5e2","trace":"de08f822acb4f5cd3f15f5acc71d6881"} +{"@timestamp":"2026-06-25T00:48:18.368+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63687 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.3ms","level":"info","span":"350d5e3a42465372","trace":"752d8aa0c958fc4c59ffe8db57b5263a"} +{"@timestamp":"2026-06-25T00:48:20.370+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63689 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.6ms","level":"info","span":"5ffbffe695db53bd","trace":"9ed86bec0d717824f6316ef7fac7dcba"} +{"@timestamp":"2026-06-25T00:48:22.366+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2059.2ms)","duration":"2059.2ms","level":"slow","span":"09f4e09e75c84dd0","trace":"2409012604f2343c7cfa75005e5a3100"} +{"@timestamp":"2026-06-25T00:48:22.366+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2059.2ms","level":"info","span":"09f4e09e75c84dd0","trace":"2409012604f2343c7cfa75005e5a3100"} +{"@timestamp":"2026-06-25T00:48:22.369+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63691 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.4ms","level":"info","span":"396e257ae4328dd6","trace":"512a26051943c6ca6d7e584458046ff1"} +{"@timestamp":"2026-06-25T00:48:24.369+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63693 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.7ms","level":"info","span":"115e4d14f00f0956","trace":"e9d2c8f01d6cc697189a55cb3a0b360c"} +{"@timestamp":"2026-06-25T00:48:26.367+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63695 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"f17578fa7ee1bca5","trace":"e40ad8b8ffc0f33fb792ce89caf432c3"} +{"@timestamp":"2026-06-25T00:48:27.444+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2074.7ms)","duration":"2074.7ms","level":"slow","span":"75d5e68de4984c17","trace":"01dae021e13245d78b9473f96710082a"} +{"@timestamp":"2026-06-25T00:48:27.445+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2074.7ms","level":"info","span":"75d5e68de4984c17","trace":"01dae021e13245d78b9473f96710082a"} +{"@timestamp":"2026-06-25T00:48:28.368+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63697 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"800f3a7424fd23d5","trace":"dd42c67ad41bd48faff5c2eb8042a092"} +{"@timestamp":"2026-06-25T00:48:30.368+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63699 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.4ms","level":"info","span":"27cfd0019dd0da62","trace":"9f5891764a0a6a1fb12cd3103b98112e"} +{"@timestamp":"2026-06-25T00:48:32.366+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63701 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"726f15e91d5f9315","trace":"1c94ee49a7cade59d8c333c67129844f"} +{"@timestamp":"2026-06-25T00:48:32.512+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2059.8ms)","duration":"2059.8ms","level":"slow","span":"a24599d99f8765c0","trace":"b32630d95cbcc50c873989868f869c00"} +{"@timestamp":"2026-06-25T00:48:32.512+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2059.8ms","level":"info","span":"a24599d99f8765c0","trace":"b32630d95cbcc50c873989868f869c00"} +{"@timestamp":"2026-06-25T00:48:34.367+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63703 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"6c517265762d379b","trace":"579557599f285c39849bbc4bf11ec059"} +{"@timestamp":"2026-06-25T00:48:36.368+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63705 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"543833df65652f0e","trace":"24d71a4a63bd97aed5cce47aab6bbcfb"} +{"@timestamp":"2026-06-25T00:48:37.609+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2092.7ms)","duration":"2092.7ms","level":"slow","span":"5ac96269fbc5c823","trace":"e9a3b7e3598eb0c5710b717b10415a05"} +{"@timestamp":"2026-06-25T00:48:37.609+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2092.7ms","level":"info","span":"5ac96269fbc5c823","trace":"e9a3b7e3598eb0c5710b717b10415a05"} +{"@timestamp":"2026-06-25T00:48:38.368+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63710 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.1ms","level":"info","span":"93fdd9ac5d55ea36","trace":"7ee12c3f2dc777f1fa77ac46013f8d93"} +{"@timestamp":"2026-06-25T00:48:40.370+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63712 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"ad5b644fa656466f","trace":"499747942113daefcf6a9f62a782e8e0"} +{"@timestamp":"2026-06-25T00:48:42.367+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63714 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"2.7ms","level":"info","span":"3fc4243edf37d509","trace":"b795af39a253f1ee6aaab71e5dbbeab8"} +{"@timestamp":"2026-06-25T00:48:42.667+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2052.7ms)","duration":"2052.7ms","level":"slow","span":"5749390fcb5946f6","trace":"4fd37718b7f73940e6f73721b19b30de"} +{"@timestamp":"2026-06-25T00:48:42.667+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2052.7ms","level":"info","span":"5749390fcb5946f6","trace":"4fd37718b7f73940e6f73721b19b30de"} +{"@timestamp":"2026-06-25T00:48:44.368+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63716 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"a2b439b513d96f3f","trace":"3fe43471367cd0bbdf1963faeba35b96"} +{"@timestamp":"2026-06-25T00:48:46.368+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63718 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"4.1ms","level":"info","span":"a1021d2b7c3d3726","trace":"3b8822e6a9d0f75d2a71836ecca05490"} +{"@timestamp":"2026-06-25T00:48:47.728+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2057.3ms)","duration":"2057.3ms","level":"slow","span":"aa22c08e40b0de3a","trace":"297f41d442730972656c53a9713c6991"} +{"@timestamp":"2026-06-25T00:48:47.728+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2057.3ms","level":"info","span":"aa22c08e40b0de3a","trace":"297f41d442730972656c53a9713c6991"} +{"@timestamp":"2026-06-25T00:48:48.107+08:00","caller":"stat/usage.go:82","content":"CPU: 0m, MEMORY: Alloc=3.5Mi, TotalAlloc=14.5Mi, Sys=19.0Mi, NumGC=9","level":"stat"} +{"@timestamp":"2026-06-25T00:48:48.369+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63720 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.8ms","level":"info","span":"b08c0877acf4dfb0","trace":"7e6f299dcc673dd7ffd9b85682045e45"} +{"@timestamp":"2026-06-25T00:48:48.629+08:00","caller":"load/sheddingstat.go:61","content":"(api) shedding_stat [1m], cpu: 0, total: 76, pass: 76, drop: 0","level":"stat"} +{"@timestamp":"2026-06-25T00:48:48.840+08:00","caller":"stat/metrics.go:210","content":"(haixun-backend) - qps: 1.3/s, drops: 0, avg time: 331.8ms, med: 3.2ms, 90th: 2059.7ms, 99th: 2103.3ms, 99.9th: 2103.3ms","level":"stat"} +{"@timestamp":"2026-06-25T00:48:50.369+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63722 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.3ms","level":"info","span":"f2495fa365b137eb","trace":"0b8efa8654a7feb62ddb86168a582937"} +{"@timestamp":"2026-06-25T00:48:52.367+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - GET /api/v1/jobs?page=1&pageSize=12 - 127.0.0.1:63724 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36","duration":"3.2ms","level":"info","span":"b6d547836dc811eb","trace":"7ed8503997b24c76cfa0ceb19589a3b0"} +{"@timestamp":"2026-06-25T00:48:52.797+08:00","caller":"handler/loghandler.go:151","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node - slowcall(2065.2ms)","duration":"2065.2ms","level":"slow","span":"0a6ecbfa11be3511","trace":"07ba6d1c295c8bb53f3919765872b64d"} +{"@timestamp":"2026-06-25T00:48:52.797+08:00","caller":"handler/loghandler.go:167","content":"[HTTP] 200 - POST /api/v1/internal/workers/jobs/claim - 127.0.0.1:63543 - node","duration":"2065.2ms","level":"info","span":"0a6ecbfa11be3511","trace":"07ba6d1c295c8bb53f3919765872b64d"} diff --git a/haixun-backend/.run/logs/web.log b/haixun-backend/.run/logs/web.log index 53088d9..48dcacc 100644 --- a/haixun-backend/.run/logs/web.log +++ b/haixun-backend/.run/logs/web.log @@ -3,7 +3,7 @@ > vite - VITE v6.4.3 ready in 221 ms + VITE v6.4.3 ready in 167 ms ➜ Local: http://localhost:5173/ ➜ Network: use --host to expose diff --git a/haixun-backend/.run/logs/worker.log b/haixun-backend/.run/logs/worker.log index 812633b..ba5386d 100644 --- a/haixun-backend/.run/logs/worker.log +++ b/haixun-backend/.run/logs/worker.log @@ -2,4 +2,4 @@ > haixun-master@0.1.0 worker:style-8d > . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts -[8d-worker] started id=local-style-8d-node-37646 api=http://127.0.0.1:8890 +[8d-worker] started id=local-style-8d-node-65878 api=http://127.0.0.1:8890 diff --git a/haixun-backend/.run/web.pid b/haixun-backend/.run/web.pid index 7210586..c9540f8 100644 --- a/haixun-backend/.run/web.pid +++ b/haixun-backend/.run/web.pid @@ -1 +1 @@ -37575 +65800 diff --git a/haixun-backend/.run/worker.pid b/haixun-backend/.run/worker.pid index b7fc34e..a3254f4 100644 --- a/haixun-backend/.run/worker.pid +++ b/haixun-backend/.run/worker.pid @@ -1 +1 @@ -37576 +65801 diff --git a/haixun-backend/deploy/docker-compose.yml b/haixun-backend/deploy/docker-compose.yml index 5eaef55..3313722 100644 --- a/haixun-backend/deploy/docker-compose.yml +++ b/haixun-backend/deploy/docker-compose.yml @@ -34,4 +34,4 @@ services: volumes: mongo_data: - redis_data: + redis_data: \ No newline at end of file diff --git a/haixun-backend/generate/api/brand.api b/haixun-backend/generate/api/brand.api index bdaa90f..d297f73 100644 --- a/haixun-backend/generate/api/brand.api +++ b/haixun-backend/generate/api/brand.api @@ -1,26 +1,92 @@ syntax = "v1" type ( + ResearchItemData { + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + Snippet string `json:"snippet,omitempty"` + Query string `json:"query,omitempty"` + } + ResearchMapData { - 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"` + 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"` + ResearchItems []ResearchItemData `json:"research_items,omitempty"` + ExpandStrategy string `json:"expand_strategy,omitempty"` + PatrolKeywords []string `json:"patrol_keywords,omitempty"` + } + + KnowledgeGraphEvidenceData { + URL string `json:"url,omitempty"` + Snippet string `json:"snippet,omitempty"` + Query string `json:"query,omitempty"` + } + + BraveSourceData { + Query string `json:"query,omitempty"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + Snippet string `json:"snippet,omitempty"` + } + + BrandProductData { + ID string `json:"id"` + Label string `json:"label"` + ProductContext string `json:"product_context"` + MatchTags []string `json:"match_tags,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` } BrandData { - ID string `json:"id"` - DisplayName string `json:"display_name,omitempty"` - SeedQuery string `json:"seed_query,omitempty"` - Brief string `json:"brief,omitempty"` - ProductBrief string `json:"product_brief,omitempty"` - ProductContext string `json:"product_context,omitempty"` - TargetAudience string `json:"target_audience,omitempty"` - Goals string `json:"goals,omitempty"` - ResearchMap ResearchMapData `json:"research_map,omitempty"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + TopicName string `json:"topic_name,omitempty"` + SeedQuery string `json:"seed_query,omitempty"` + Brief string `json:"brief,omitempty"` + ProductBrief string `json:"product_brief,omitempty"` + ProductContext string `json:"product_context,omitempty"` + ProductID string `json:"product_id,omitempty"` + Products []BrandProductData `json:"products,omitempty"` + TargetAudience string `json:"target_audience,omitempty"` + Goals string `json:"goals,omitempty"` + ResearchMap ResearchMapData `json:"research_map,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + ListBrandProductsData { + List []BrandProductData `json:"list"` + } + + CreateBrandProductReq { + Label string `json:"label" validate:"required"` + ProductContext string `json:"product_context" validate:"required"` + MatchTags []string `json:"match_tags,optional"` + } + + UpdateBrandProductReq { + Label *string `json:"label,optional"` + ProductContext *string `json:"product_context,optional"` + MatchTags []string `json:"match_tags,optional"` + } + + BrandProductPath { + ID string `path:"id" validate:"required"` + ProductID string `path:"productId" validate:"required"` + } + + CreateBrandProductHandlerReq { + BrandPath + CreateBrandProductReq + } + + UpdateBrandProductHandlerReq { + BrandProductPath + UpdateBrandProductReq } ListBrandsData { @@ -36,27 +102,36 @@ type ( } UpdateBrandReq { - DisplayName *string `json:"display_name,optional"` - SeedQuery *string `json:"seed_query,optional"` - Brief *string `json:"brief,optional"` - ProductBrief *string `json:"product_brief,optional"` - ProductContext *string `json:"product_context,optional"` - TargetAudience *string `json:"target_audience,optional"` - Goals *string `json:"goals,optional"` + DisplayName *string `json:"display_name,optional"` + TopicName *string `json:"topic_name,optional"` + SeedQuery *string `json:"seed_query,optional"` + Brief *string `json:"brief,optional"` + ProductBrief *string `json:"product_brief,optional"` + ProductContext *string `json:"product_context,optional"` + ProductID *string `json:"product_id,optional"` + TargetAudience *string `json:"target_audience,optional"` + Goals *string `json:"goals,optional"` + AudienceSummary *string `json:"audience_summary,optional"` + ContentGoal *string `json:"content_goal,optional"` + Questions []string `json:"questions,optional"` + Pillars []string `json:"pillars,optional"` + Exclusions []string `json:"exclusions,optional"` + PatrolKeywords []string `json:"patrol_keywords,optional"` } KnowledgeGraphNodeData { - ID string `json:"id"` - Label string `json:"label"` - NodeKind string `json:"node_kind"` - Type string `json:"type"` - Layer int `json:"layer"` - Relation string `json:"relation,omitempty"` - PlacementValue string `json:"placement_value,omitempty"` - ProductFitScore int `json:"product_fit_score"` - SelectedForScan bool `json:"selected_for_scan"` - RelevanceTags []string `json:"relevance_tags"` - RecencyTags []string `json:"recency_tags"` + ID string `json:"id"` + Label string `json:"label"` + NodeKind string `json:"node_kind"` + Type string `json:"type"` + Layer int `json:"layer"` + Relation string `json:"relation,omitempty"` + PlacementValue string `json:"placement_value,omitempty"` + ProductFitScore int `json:"product_fit_score"` + SelectedForScan bool `json:"selected_for_scan"` + RelevanceTags []string `json:"relevance_tags"` + RecencyTags []string `json:"recency_tags"` + Evidence []KnowledgeGraphEvidenceData `json:"evidence,omitempty"` } KnowledgeGraphEdgeData { @@ -66,20 +141,24 @@ type ( } KnowledgeGraphData { - ID string `json:"id"` - BrandID string `json:"brand_id"` - Seed string `json:"seed"` - Nodes []KnowledgeGraphNodeData `json:"nodes"` - Edges []KnowledgeGraphEdgeData `json:"edges"` - PainTagCount int `json:"pain_tag_count"` - GeneratedAt int64 `json:"generated_at"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` + ID string `json:"id"` + BrandID string `json:"brand_id"` + Seed string `json:"seed"` + Nodes []KnowledgeGraphNodeData `json:"nodes"` + Edges []KnowledgeGraphEdgeData `json:"edges"` + BraveSources []BraveSourceData `json:"brave_sources,omitempty"` + ExpandStrategy string `json:"expand_strategy,omitempty"` + PainTagCount int `json:"pain_tag_count"` + GeneratedAt int64 `json:"generated_at"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` } ExpandKnowledgeGraphReq { - SeedQuery string `json:"seed_query" validate:"required"` - Supplemental bool `json:"supplemental,optional"` + SeedQuery string `json:"seed_query" validate:"required"` + Supplemental bool `json:"supplemental,optional"` + RegenerateMap bool `json:"regenerate_map,optional"` + ExpandStrategy string `json:"expand_strategy,optional"` // brave | llm | hybrid } ExpandKnowledgeGraphData { @@ -89,8 +168,10 @@ type ( } KnowledgeGraphNodeUpdate { - NodeID string `json:"node_id" validate:"required"` - SelectedForScan bool `json:"selected_for_scan"` + NodeID string `json:"node_id" validate:"required"` + SelectedForScan *bool `json:"selected_for_scan,optional"` + RelevanceTags []string `json:"relevance_tags,optional"` + RecencyTags []string `json:"recency_tags,optional"` } PatchKnowledgeGraphNodesReq { @@ -98,9 +179,10 @@ type ( } StartBrandScanJobReq { - GraphID string `json:"graph_id,optional"` - NodeIDs []string `json:"node_ids,optional"` - DualTrack bool `json:"dual_track,optional"` + GraphID string `json:"graph_id,optional"` + NodeIDs []string `json:"node_ids,optional"` + DualTrack bool `json:"dual_track,optional"` + PatrolMode bool `json:"patrol_mode,optional"` } StartBrandScanJobData { @@ -135,7 +217,9 @@ type ( PublishedReplyID string `json:"published_reply_id,omitempty"` PublishedPermalink string `json:"published_permalink,omitempty"` OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"` + PostedAt string `json:"posted_at,omitempty"` Replies []ScanReplyData `json:"replies,omitempty"` + LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"` CreateAt int64 `json:"create_at"` } @@ -148,6 +232,7 @@ type ( ScanPostID string `json:"scan_post_id" validate:"required"` Count int `json:"count,optional"` VoicePersonaID string `json:"voice_persona_id,optional"` + ProductID string `json:"product_id,optional"` } OutreachDraftItemData { @@ -308,6 +393,18 @@ service gateway { @handler deleteBrand delete /:id (BrandPath) + @handler listBrandProducts + get /:id/products (BrandPath) returns (ListBrandProductsData) + + @handler createBrandProduct + post /:id/products (CreateBrandProductHandlerReq) returns (BrandProductData) + + @handler updateBrandProduct + patch /:id/products/:productId (UpdateBrandProductHandlerReq) returns (BrandProductData) + + @handler deleteBrandProduct + delete /:id/products/:productId (BrandProductPath) + @handler expandKnowledgeGraph post /:id/knowledge-graph/expand (ExpandKnowledgeGraphHandlerReq) returns (ExpandKnowledgeGraphData) @@ -343,4 +440,4 @@ service gateway { @handler upsertBrandScanSchedule put /:id/scan-schedule (UpsertBrandScanScheduleHandlerReq) returns (BrandScanScheduleData) -} \ No newline at end of file +} diff --git a/haixun-backend/generate/api/gateway.api b/haixun-backend/generate/api/gateway.api index 37133a1..9429e51 100644 --- a/haixun-backend/generate/api/gateway.api +++ b/haixun-backend/generate/api/gateway.api @@ -23,6 +23,7 @@ import ( "threads_account.api" "persona.api" "brand.api" + "placement_topic.api" "worker_internal.api" ) diff --git a/haixun-backend/generate/api/member.api b/haixun-backend/generate/api/member.api index c62d142..e795a4b 100644 --- a/haixun-backend/generate/api/member.api +++ b/haixun-backend/generate/api/member.api @@ -34,12 +34,14 @@ type ( BraveAPIKeyConfigured bool `json:"brave_api_key_configured"` BraveCountry string `json:"brave_country"` BraveSearchLang string `json:"brave_search_lang"` + ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid } UpdateMemberPlacementSettingsReq { BraveAPIKey *string `json:"brave_api_key,optional"` BraveCountry *string `json:"brave_country,optional"` BraveSearchLang *string `json:"brave_search_lang,optional"` + ExpandStrategy *string `json:"expand_strategy,optional"` } ) diff --git a/haixun-backend/generate/api/placement_topic.api b/haixun-backend/generate/api/placement_topic.api new file mode 100644 index 0000000..a6d66a1 --- /dev/null +++ b/haixun-backend/generate/api/placement_topic.api @@ -0,0 +1,185 @@ +syntax = "v1" + +type ( + PlacementTopicData { + ID string `json:"id"` + BrandID string `json:"brand_id"` + BrandDisplayName string `json:"brand_display_name,omitempty"` + TopicName string `json:"topic_name,omitempty"` + SeedQuery string `json:"seed_query,omitempty"` + Brief string `json:"brief,omitempty"` + ProductID string `json:"product_id,omitempty"` + ResearchMap ResearchMapData `json:"research_map,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + ListPlacementTopicsData { + List []PlacementTopicData `json:"list"` + } + + CreatePlacementTopicReq { + BrandID string `json:"brand_id" validate:"required"` + TopicName string `json:"topic_name" validate:"required"` + SeedQuery string `json:"seed_query" validate:"required"` + Brief string `json:"brief" validate:"required"` + ProductID string `json:"product_id,optional"` + } + + UpdatePlacementTopicReq { + BrandID *string `json:"brand_id,optional"` + TopicName *string `json:"topic_name,optional"` + SeedQuery *string `json:"seed_query,optional"` + Brief *string `json:"brief,optional"` + ProductID *string `json:"product_id,optional"` + AudienceSummary *string `json:"audience_summary,optional"` + ContentGoal *string `json:"content_goal,optional"` + Questions []string `json:"questions,optional"` + Pillars []string `json:"pillars,optional"` + Exclusions []string `json:"exclusions,optional"` + PatrolKeywords []string `json:"patrol_keywords,optional"` + } + + PlacementTopicPath { + ID string `path:"id" validate:"required"` + } + + UpdatePlacementTopicHandlerReq { + PlacementTopicPath + UpdatePlacementTopicReq + } + + CreatePlacementTopicHandlerReq { + CreatePlacementTopicReq + } + + ExpandPlacementTopicGraphHandlerReq { + PlacementTopicPath + ExpandKnowledgeGraphReq + } + + PatchPlacementTopicGraphNodesHandlerReq { + PlacementTopicPath + PatchKnowledgeGraphNodesReq + } + + StartPlacementTopicScanJobHandlerReq { + PlacementTopicPath + StartBrandScanJobReq + } + + ListPlacementTopicScanPostsHandlerReq { + PlacementTopicPath + ListBrandScanPostsReq + } + + GeneratePlacementTopicOutreachDraftsHandlerReq { + PlacementTopicPath + GenerateOutreachDraftsReq + } + + PublishPlacementTopicOutreachDraftHandlerReq { + PlacementTopicPath + PublishOutreachDraftReq + } + + PatchPlacementTopicScanPostOutreachHandlerReq { + PlacementTopicPath + PostID string `path:"postId"` + PatchScanPostOutreachReq + } + + DeletePlacementTopicScanPostHandlerReq { + PlacementTopicPath + PostID string `path:"postId" validate:"required"` + } + + BatchDeletePlacementTopicScanPostsReq { + PostIDs []string `json:"post_ids" validate:"required"` + } + + BatchDeletePlacementTopicScanPostsData { + DeletedCount int `json:"deleted_count"` + } + + BatchDeletePlacementTopicScanPostsHandlerReq { + PlacementTopicPath + BatchDeletePlacementTopicScanPostsReq + } + + GeneratePlacementTopicContentMatrixHandlerReq { + PlacementTopicPath + GenerateContentMatrixReq + } + + UpsertPlacementTopicScanScheduleHandlerReq { + PlacementTopicPath + UpsertBrandScanScheduleReq + } +) + +@server( + group: placement_topic + prefix: /api/v1/placement/topics + middleware: AuthJWT + tags: "Placement Topic" + summary: "找 TA 主題;每個主題關聯一個品牌,一個品牌可有多個主題。Requires Bearer JWT." +) +service gateway { + @handler listPlacementTopics + get / returns (ListPlacementTopicsData) + + @handler createPlacementTopic + post / (CreatePlacementTopicHandlerReq) returns (PlacementTopicData) + + @handler getPlacementTopic + get /:id (PlacementTopicPath) returns (PlacementTopicData) + + @handler updatePlacementTopic + patch /:id (UpdatePlacementTopicHandlerReq) returns (PlacementTopicData) + + @handler deletePlacementTopic + delete /:id (PlacementTopicPath) + + @handler expandPlacementTopicGraph + post /:id/knowledge-graph/expand (ExpandPlacementTopicGraphHandlerReq) returns (ExpandKnowledgeGraphData) + + @handler getPlacementTopicGraph + get /:id/knowledge-graph (PlacementTopicPath) returns (KnowledgeGraphData) + + @handler patchPlacementTopicGraphNodes + patch /:id/knowledge-graph/nodes (PatchPlacementTopicGraphNodesHandlerReq) returns (KnowledgeGraphData) + + @handler startPlacementTopicScanJob + post /:id/scan-jobs (StartPlacementTopicScanJobHandlerReq) returns (StartBrandScanJobData) + + @handler listPlacementTopicScanPosts + get /:id/scan-posts (ListPlacementTopicScanPostsHandlerReq) returns (ListBrandScanPostsData) + + @handler generatePlacementTopicOutreachDrafts + post /:id/outreach-drafts/generate (GeneratePlacementTopicOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData) + + @handler publishPlacementTopicOutreachDraft + post /:id/outreach-drafts/publish (PublishPlacementTopicOutreachDraftHandlerReq) returns (PublishOutreachDraftData) + + @handler patchPlacementTopicScanPostOutreach + patch /:id/scan-posts/:postId (PatchPlacementTopicScanPostOutreachHandlerReq) returns (ScanPostData) + + @handler deletePlacementTopicScanPost + delete /:id/scan-posts/:postId (DeletePlacementTopicScanPostHandlerReq) + + @handler batchDeletePlacementTopicScanPosts + post /:id/scan-posts/batch-delete (BatchDeletePlacementTopicScanPostsHandlerReq) returns (BatchDeletePlacementTopicScanPostsData) + + @handler getPlacementTopicContentMatrix + get /:id/content-matrix (PlacementTopicPath) returns (ContentMatrixData) + + @handler generatePlacementTopicContentMatrix + post /:id/content-matrix/generate (GeneratePlacementTopicContentMatrixHandlerReq) returns (ContentMatrixData) + + @handler getPlacementTopicScanSchedule + get /:id/scan-schedule (PlacementTopicPath) returns (BrandScanScheduleData) + + @handler upsertPlacementTopicScanSchedule + put /:id/scan-schedule (UpsertPlacementTopicScanScheduleHandlerReq) returns (BrandScanScheduleData) +} diff --git a/haixun-backend/internal/handler/brand/create_brand_product_handler.go b/haixun-backend/internal/handler/brand/create_brand_product_handler.go new file mode 100644 index 0000000..9975349 --- /dev/null +++ b/haixun-backend/internal/handler/brand/create_brand_product_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package brand + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/brand" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CreateBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreateBrandProductHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := brand.NewCreateBrandProductLogic(r.Context(), svcCtx) + data, err := l.CreateBrandProduct(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/brand/delete_brand_product_handler.go b/haixun-backend/internal/handler/brand/delete_brand_product_handler.go new file mode 100644 index 0000000..fedc915 --- /dev/null +++ b/haixun-backend/internal/handler/brand/delete_brand_product_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package brand + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/brand" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func DeleteBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.BrandProductPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := brand.NewDeleteBrandProductLogic(r.Context(), svcCtx) + err := l.DeleteBrandProduct(&req) + response.Write(r.Context(), w, nil, err) + } +} diff --git a/haixun-backend/internal/handler/brand/list_brand_products_handler.go b/haixun-backend/internal/handler/brand/list_brand_products_handler.go new file mode 100644 index 0000000..382613e --- /dev/null +++ b/haixun-backend/internal/handler/brand/list_brand_products_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package brand + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/brand" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListBrandProductsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.BrandPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := brand.NewListBrandProductsLogic(r.Context(), svcCtx) + data, err := l.ListBrandProducts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/brand/update_brand_product_handler.go b/haixun-backend/internal/handler/brand/update_brand_product_handler.go new file mode 100644 index 0000000..0c0136a --- /dev/null +++ b/haixun-backend/internal/handler/brand/update_brand_product_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package brand + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/brand" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func UpdateBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpdateBrandProductHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := brand.NewUpdateBrandProductLogic(r.Context(), svcCtx) + data, err := l.UpdateBrandProduct(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/batch_delete_placement_topic_scan_posts_handler.go b/haixun-backend/internal/handler/placement_topic/batch_delete_placement_topic_scan_posts_handler.go new file mode 100644 index 0000000..a21cd8e --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/batch_delete_placement_topic_scan_posts_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func BatchDeletePlacementTopicScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.BatchDeletePlacementTopicScanPostsHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewBatchDeletePlacementTopicScanPostsLogic(r.Context(), svcCtx) + data, err := l.BatchDeletePlacementTopicScanPosts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/create_placement_topic_handler.go b/haixun-backend/internal/handler/placement_topic/create_placement_topic_handler.go new file mode 100644 index 0000000..1ee8428 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/create_placement_topic_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func CreatePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.CreatePlacementTopicHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewCreatePlacementTopicLogic(r.Context(), svcCtx) + data, err := l.CreatePlacementTopic(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/delete_placement_topic_handler.go b/haixun-backend/internal/handler/placement_topic/delete_placement_topic_handler.go new file mode 100644 index 0000000..d157030 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/delete_placement_topic_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func DeletePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlacementTopicPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewDeletePlacementTopicLogic(r.Context(), svcCtx) + err := l.DeletePlacementTopic(&req) + response.Write(r.Context(), w, nil, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/delete_placement_topic_scan_post_handler.go b/haixun-backend/internal/handler/placement_topic/delete_placement_topic_scan_post_handler.go new file mode 100644 index 0000000..ca67242 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/delete_placement_topic_scan_post_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func DeletePlacementTopicScanPostHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.DeletePlacementTopicScanPostHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewDeletePlacementTopicScanPostLogic(r.Context(), svcCtx) + err := l.DeletePlacementTopicScanPost(&req) + response.Write(r.Context(), w, nil, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/expand_placement_topic_graph_handler.go b/haixun-backend/internal/handler/placement_topic/expand_placement_topic_graph_handler.go new file mode 100644 index 0000000..3ab53b7 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/expand_placement_topic_graph_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ExpandPlacementTopicGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ExpandPlacementTopicGraphHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewExpandPlacementTopicGraphLogic(r.Context(), svcCtx) + data, err := l.ExpandPlacementTopicGraph(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/generate_placement_topic_content_matrix_handler.go b/haixun-backend/internal/handler/placement_topic/generate_placement_topic_content_matrix_handler.go new file mode 100644 index 0000000..b01d389 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/generate_placement_topic_content_matrix_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GeneratePlacementTopicContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GeneratePlacementTopicContentMatrixHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewGeneratePlacementTopicContentMatrixLogic(r.Context(), svcCtx) + data, err := l.GeneratePlacementTopicContentMatrix(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/generate_placement_topic_outreach_drafts_handler.go b/haixun-backend/internal/handler/placement_topic/generate_placement_topic_outreach_drafts_handler.go new file mode 100644 index 0000000..32fcaa1 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/generate_placement_topic_outreach_drafts_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GeneratePlacementTopicOutreachDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.GeneratePlacementTopicOutreachDraftsHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewGeneratePlacementTopicOutreachDraftsLogic(r.Context(), svcCtx) + data, err := l.GeneratePlacementTopicOutreachDrafts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/get_placement_topic_content_matrix_handler.go b/haixun-backend/internal/handler/placement_topic/get_placement_topic_content_matrix_handler.go new file mode 100644 index 0000000..a0790df --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/get_placement_topic_content_matrix_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetPlacementTopicContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlacementTopicPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewGetPlacementTopicContentMatrixLogic(r.Context(), svcCtx) + data, err := l.GetPlacementTopicContentMatrix(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/get_placement_topic_graph_handler.go b/haixun-backend/internal/handler/placement_topic/get_placement_topic_graph_handler.go new file mode 100644 index 0000000..c9aac44 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/get_placement_topic_graph_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetPlacementTopicGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlacementTopicPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewGetPlacementTopicGraphLogic(r.Context(), svcCtx) + data, err := l.GetPlacementTopicGraph(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/get_placement_topic_handler.go b/haixun-backend/internal/handler/placement_topic/get_placement_topic_handler.go new file mode 100644 index 0000000..c8bb3de --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/get_placement_topic_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetPlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlacementTopicPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewGetPlacementTopicLogic(r.Context(), svcCtx) + data, err := l.GetPlacementTopic(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/get_placement_topic_scan_schedule_handler.go b/haixun-backend/internal/handler/placement_topic/get_placement_topic_scan_schedule_handler.go new file mode 100644 index 0000000..2441b38 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/get_placement_topic_scan_schedule_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func GetPlacementTopicScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PlacementTopicPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewGetPlacementTopicScanScheduleLogic(r.Context(), svcCtx) + data, err := l.GetPlacementTopicScanSchedule(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/list_placement_topic_scan_posts_handler.go b/haixun-backend/internal/handler/placement_topic/list_placement_topic_scan_posts_handler.go new file mode 100644 index 0000000..040db9b --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/list_placement_topic_scan_posts_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func ListPlacementTopicScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListPlacementTopicScanPostsHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewListPlacementTopicScanPostsLogic(r.Context(), svcCtx) + data, err := l.ListPlacementTopicScanPosts(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/list_placement_topics_handler.go b/haixun-backend/internal/handler/placement_topic/list_placement_topics_handler.go new file mode 100644 index 0000000..bbbd62d --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/list_placement_topics_handler.go @@ -0,0 +1,20 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" +) + +func ListPlacementTopicsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := placement_topic.NewListPlacementTopicsLogic(r.Context(), svcCtx) + data, err := l.ListPlacementTopics() + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/patch_placement_topic_graph_nodes_handler.go b/haixun-backend/internal/handler/placement_topic/patch_placement_topic_graph_nodes_handler.go new file mode 100644 index 0000000..ff84179 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/patch_placement_topic_graph_nodes_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func PatchPlacementTopicGraphNodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PatchPlacementTopicGraphNodesHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewPatchPlacementTopicGraphNodesLogic(r.Context(), svcCtx) + data, err := l.PatchPlacementTopicGraphNodes(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/patch_placement_topic_scan_post_outreach_handler.go b/haixun-backend/internal/handler/placement_topic/patch_placement_topic_scan_post_outreach_handler.go new file mode 100644 index 0000000..5c20f60 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/patch_placement_topic_scan_post_outreach_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func PatchPlacementTopicScanPostOutreachHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PatchPlacementTopicScanPostOutreachHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewPatchPlacementTopicScanPostOutreachLogic(r.Context(), svcCtx) + data, err := l.PatchPlacementTopicScanPostOutreach(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/publish_placement_topic_outreach_draft_handler.go b/haixun-backend/internal/handler/placement_topic/publish_placement_topic_outreach_draft_handler.go new file mode 100644 index 0000000..05d691a --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/publish_placement_topic_outreach_draft_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func PublishPlacementTopicOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.PublishPlacementTopicOutreachDraftHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewPublishPlacementTopicOutreachDraftLogic(r.Context(), svcCtx) + data, err := l.PublishPlacementTopicOutreachDraft(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/start_placement_topic_scan_job_handler.go b/haixun-backend/internal/handler/placement_topic/start_placement_topic_scan_job_handler.go new file mode 100644 index 0000000..6ec3868 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/start_placement_topic_scan_job_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func StartPlacementTopicScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.StartPlacementTopicScanJobHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewStartPlacementTopicScanJobLogic(r.Context(), svcCtx) + data, err := l.StartPlacementTopicScanJob(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/update_placement_topic_handler.go b/haixun-backend/internal/handler/placement_topic/update_placement_topic_handler.go new file mode 100644 index 0000000..f8b1f8b --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/update_placement_topic_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func UpdatePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpdatePlacementTopicHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewUpdatePlacementTopicLogic(r.Context(), svcCtx) + data, err := l.UpdatePlacementTopic(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/placement_topic/upsert_placement_topic_scan_schedule_handler.go b/haixun-backend/internal/handler/placement_topic/upsert_placement_topic_scan_schedule_handler.go new file mode 100644 index 0000000..1563d31 --- /dev/null +++ b/haixun-backend/internal/handler/placement_topic/upsert_placement_topic_scan_schedule_handler.go @@ -0,0 +1,32 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "net/http" + + "github.com/zeromicro/go-zero/rest/httpx" + "haixun-backend/internal/logic/placement_topic" + "haixun-backend/internal/response" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" +) + +func UpsertPlacementTopicScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpsertPlacementTopicScanScheduleHandlerReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := placement_topic.NewUpsertPlacementTopicScanScheduleLogic(r.Context(), svcCtx) + data, err := l.UpsertPlacementTopicScanSchedule(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/haixun-backend/internal/handler/routes.go b/haixun-backend/internal/handler/routes.go index ea29982..be2b4bd 100644 --- a/haixun-backend/internal/handler/routes.go +++ b/haixun-backend/internal/handler/routes.go @@ -14,6 +14,7 @@ import ( normal "haixun-backend/internal/handler/normal" permission "haixun-backend/internal/handler/permission" persona "haixun-backend/internal/handler/persona" + placement_topic "haixun-backend/internal/handler/placement_topic" setting "haixun-backend/internal/handler/setting" threads_account "haixun-backend/internal/handler/threads_account" "haixun-backend/internal/svc" @@ -170,6 +171,26 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/:id/outreach-drafts/publish", Handler: brand.PublishOutreachDraftHandler(serverCtx), }, + { + Method: http.MethodGet, + Path: "/:id/products", + Handler: brand.ListBrandProductsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/products", + Handler: brand.CreateBrandProductHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/:id/products/:productId", + Handler: brand.UpdateBrandProductHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id/products/:productId", + Handler: brand.DeleteBrandProductHandler(serverCtx), + }, { Method: http.MethodPost, Path: "/:id/scan-jobs", @@ -456,6 +477,110 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/personas"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/", + Handler: placement_topic.ListPlacementTopicsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/", + Handler: placement_topic.CreatePlacementTopicHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id", + Handler: placement_topic.GetPlacementTopicHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/:id", + Handler: placement_topic.UpdatePlacementTopicHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id", + Handler: placement_topic.DeletePlacementTopicHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/content-matrix", + Handler: placement_topic.GetPlacementTopicContentMatrixHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/content-matrix/generate", + Handler: placement_topic.GeneratePlacementTopicContentMatrixHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/knowledge-graph", + Handler: placement_topic.GetPlacementTopicGraphHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/knowledge-graph/expand", + Handler: placement_topic.ExpandPlacementTopicGraphHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/:id/knowledge-graph/nodes", + Handler: placement_topic.PatchPlacementTopicGraphNodesHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/outreach-drafts/generate", + Handler: placement_topic.GeneratePlacementTopicOutreachDraftsHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/outreach-drafts/publish", + Handler: placement_topic.PublishPlacementTopicOutreachDraftHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/scan-jobs", + Handler: placement_topic.StartPlacementTopicScanJobHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/scan-posts", + Handler: placement_topic.ListPlacementTopicScanPostsHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/:id/scan-posts/:postId", + Handler: placement_topic.PatchPlacementTopicScanPostOutreachHandler(serverCtx), + }, + { + Method: http.MethodDelete, + Path: "/:id/scan-posts/:postId", + Handler: placement_topic.DeletePlacementTopicScanPostHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/:id/scan-posts/batch-delete", + Handler: placement_topic.BatchDeletePlacementTopicScanPostsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/:id/scan-schedule", + Handler: placement_topic.GetPlacementTopicScanScheduleHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/:id/scan-schedule", + Handler: placement_topic.UpsertPlacementTopicScanScheduleHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/placement/topics"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, diff --git a/haixun-backend/internal/library/knowledge/bootstrap.go b/haixun-backend/internal/library/knowledge/bootstrap.go new file mode 100644 index 0000000..9f74c7c --- /dev/null +++ b/haixun-backend/internal/library/knowledge/bootstrap.go @@ -0,0 +1,131 @@ +package knowledge + +import ( + "strings" + "unicode/utf8" + + "github.com/google/uuid" +) + +const ( + maxPatrolLabelRunes = 14 + minBreadthNodeCount = 12 +) + +func SupplementGraphFromResearchMap(g *Graph, seed string, pillars, questions []string) { + if g == nil { + return + } + seen := nodeLabelSet(g.Nodes) + ensureSeedCore(g, seed, seen) + for _, pillar := range pillars { + if len(g.Nodes) >= 32 { + break + } + addBreadthNode(g, strings.TrimSpace(pillar), 1, "symptom", seen, pillar, "") + } + for _, question := range questions { + if len(g.Nodes) >= 32 { + break + } + q := strings.TrimSpace(question) + if q == "" { + continue + } + label := patrolLabelFromQuestion(q) + addBreadthNode(g, label, 2, "pain", seen, q, defaultPlacementForQuestion(q)) + } +} + +func ensureSeedCore(g *Graph, seed string, seen map[string]struct{}) { + seed = strings.TrimSpace(seed) + if seed == "" { + return + } + if _, ok := seen[strings.ToLower(seed)]; ok { + return + } + g.Nodes = append([]Node{{ + ID: uuid.NewString(), + Label: seed, + NodeKind: "pain", + Type: "core", + Layer: 0, + Relation: "核心種子主題,使用者圍繞此困擾在社群發文求助", + PlacementValue: "核心討論串最常求推薦與真實心得,適合以產品使用經驗自然回覆", + ProductFitScore: 90, + }}, g.Nodes...) + seen[strings.ToLower(seed)] = struct{}{} +} + +func addBreadthNode(g *Graph, label string, layer int, kind string, seen map[string]struct{}, relation, placement string) { + label = strings.TrimSpace(label) + if label == "" { + return + } + key := strings.ToLower(label) + if _, ok := seen[key]; ok { + return + } + if relation == "" { + relation = "與種子主題相關的「" + label + "」討論,常見於 Threads 求助串" + } + if placement == "" { + placement = "這類帖常求產品推薦或使用心得,適合以自身經驗自然回覆" + } + g.Nodes = append(g.Nodes, Node{ + ID: uuid.NewString(), + Label: label, + NodeKind: kind, + Type: breadthNodeType(layer, kind), + Layer: layer, + Relation: relation, + PlacementValue: placement, + ProductFitScore: defaultProductFit(kind, layer), + }) + seen[key] = struct{}{} +} + +func breadthNodeType(layer int, kind string) string { + if layer == 0 { + return "core" + } + if kind == "cause" { + return "cause" + } + if kind == "symptom" { + return "symptom" + } + return "mechanism" +} + +func nodeLabelSet(nodes []Node) map[string]struct{} { + seen := map[string]struct{}{} + for _, node := range nodes { + label := strings.ToLower(strings.TrimSpace(node.Label)) + if label != "" { + seen[label] = struct{}{} + } + } + return seen +} + +func patrolLabelFromQuestion(question string) string { + question = strings.TrimSpace(question) + if question == "" { + return "" + } + if utf8.RuneCountInString(question) <= maxPatrolLabelRunes { + return question + } + runes := []rune(question) + return string(runes[:maxPatrolLabelRunes]) +} + +func defaultPlacementForQuestion(question string) string { + return "受眾常這樣發文求助,適合在留言區以產品使用經驗回覆「" + strings.TrimSpace(question) + "」這類問題" +} + +func GraphNeedsBootstrap(g Graph) bool { + return len(g.Nodes) < minBreadthNodeCount +} diff --git a/haixun-backend/internal/library/knowledge/bootstrap_test.go b/haixun-backend/internal/library/knowledge/bootstrap_test.go new file mode 100644 index 0000000..55b0288 --- /dev/null +++ b/haixun-backend/internal/library/knowledge/bootstrap_test.go @@ -0,0 +1,47 @@ +package knowledge + +import "testing" + +func TestSupplementGraphFromResearchMap(t *testing.T) { + graph := Graph{Seed: "化療 沐浴乳", Nodes: []Node{}} + pillars := []string{ + "化療皮膚敏感無香沐浴乳", + "乳癌病友沐浴用品挑選", + "荷爾蒙治療對香味敏感", + "癌症康復後換清潔品牌", + "抗敏無香沐浴乳推薦", + "化療期間皮膚照護", + } + questions := []string{ + "化療後皮膚敏感要換什麼沐浴乳", + "乳癌治療中不能用有香味的沐浴乳嗎", + "癌症病人適合用的無香沐浴乳推薦", + "荷爾蒙治療皮膚乾癢怎麼挑沐浴乳", + "打標靶後對香味很敏感怎麼辦", + "康復後不想再用有香精的清潔用品", + "癌症病友都用什麼牌子沐浴乳", + "化療期間沐浴乳挑選經驗分享", + } + SupplementGraphFromResearchMap(&graph, "化療 沐浴乳", pillars, questions) + if len(graph.Nodes) < 12 { + t.Fatalf("expected >=12 nodes, got %d", len(graph.Nodes)) + } + DeriveSearchTagsFromGraph(&graph, PatrolTagInput{ + ProductName: "抗敏無香沐浴乳", + Questions: questions, + Pillars: pillars, + }) + if graph.PainTagCount < 8 { + t.Fatalf("expected pain tags, got %d", graph.PainTagCount) + } + for _, node := range graph.Nodes { + if len(node.DerivedTags.Relevance)+len(node.DerivedTags.Recency) == 0 { + continue + } + for _, tag := range append(node.DerivedTags.Relevance, node.DerivedTags.Recency...) { + if len([]rune(tag)) < 6 { + t.Fatalf("tag too short %q", tag) + } + } + } +} diff --git a/haixun-backend/internal/library/knowledge/brave_collect.go b/haixun-backend/internal/library/knowledge/brave_collect.go new file mode 100644 index 0000000..4fd9b1a --- /dev/null +++ b/haixun-backend/internal/library/knowledge/brave_collect.go @@ -0,0 +1,279 @@ +package knowledge + +import ( + "context" + "strings" + "sync" + "sync/atomic" + + libbrave "haixun-backend/internal/library/brave" +) + +type BraveSearchLocale struct { + Country string + SearchLang string +} + +type BraveCollectConfig struct { + ResultsPerQuery int + MinSourcesBeforeStop int + MaxSourcesCap int + Concurrency int +} + +func BraveCollectConfigFromQueryCfg(cfg queryConfig) BraveCollectConfig { + out := BraveCollectConfig{ + ResultsPerQuery: cfg.ResultsPerQuery, + MinSourcesBeforeStop: cfg.MinSourcesBeforeStop, + MaxSourcesCap: cfg.MaxSourcesCap, + Concurrency: cfg.BraveCollectConcurrency, + } + if out.ResultsPerQuery <= 0 { + out.ResultsPerQuery = 8 + } + if out.MinSourcesBeforeStop <= 0 { + out.MinSourcesBeforeStop = 18 + } + if out.MaxSourcesCap <= 0 { + out.MaxSourcesCap = 32 + } + if out.Concurrency <= 0 { + out.Concurrency = 4 + } + return out +} + +func DefaultBraveCollectConfig() BraveCollectConfig { + cfg, err := loadQueryConfig() + if err != nil { + return BraveCollectConfig{ResultsPerQuery: 8, MinSourcesBeforeStop: 18, MaxSourcesCap: 32, Concurrency: 4} + } + return BraveCollectConfigFromQueryCfg(cfg) +} + +func CollectBraveSources( + ctx context.Context, + client *libbrave.Client, + locale BraveSearchLocale, + queries []string, + cfg BraveCollectConfig, + onProgress func(i, total int), + heartbeat func() error, +) []BraveSource { + if client == nil || !client.Enabled() || len(queries) == 0 { + return nil + } + if cfg.Concurrency <= 1 { + return collectBraveSourcesSequential(ctx, client, locale, queries, cfg, onProgress, heartbeat) + } + return collectBraveSourcesParallel(ctx, client, locale, queries, cfg, onProgress, heartbeat) +} + +func collectBraveSourcesSequential( + ctx context.Context, + client *libbrave.Client, + locale BraveSearchLocale, + queries []string, + cfg BraveCollectConfig, + onProgress func(i, total int), + heartbeat func() error, +) []BraveSource { + capHint := cfg.MaxSourcesCap + if est := len(queries) * cfg.ResultsPerQuery; est < capHint { + capHint = est + } + out := make([]BraveSource, 0, capHint) + seenURL := map[string]struct{}{} + + for i, query := range queries { + if shouldStopCollect(out, cfg) { + break + } + if heartbeat != nil { + if err := heartbeat(); err != nil { + return out + } + } + appendBraveResults(&out, seenURL, query, searchBraveQuery(ctx, client, locale, query, cfg.ResultsPerQuery)) + if onProgress != nil { + onProgress(i, len(queries)) + } + } + return out +} + +type braveCollectState struct { + cfg BraveCollectConfig + mu sync.Mutex + out []BraveSource + seenURL map[string]struct{} + stop bool + completed int32 +} + +func (s *braveCollectState) shouldStop(cfg BraveCollectConfig) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.stop { + return true + } + if shouldStopCollect(s.out, cfg) { + s.stop = true + return true + } + return false +} + +func (s *braveCollectState) appendResults(query string, items []BraveSource) { + s.mu.Lock() + defer s.mu.Unlock() + if s.stop { + return + } + appendBraveResults(&s.out, s.seenURL, query, items) + if shouldStopCollect(s.out, s.cfg) { + s.stop = true + } +} + +func collectBraveSourcesParallel( + ctx context.Context, + client *libbrave.Client, + locale BraveSearchLocale, + queries []string, + cfg BraveCollectConfig, + onProgress func(i, total int), + heartbeat func() error, +) []BraveSource { + state := &braveCollectState{ + cfg: cfg, + out: make([]BraveSource, 0, cfg.MaxSourcesCap), + seenURL: map[string]struct{}{}, + } + + workers := cfg.Concurrency + if workers > len(queries) { + workers = len(queries) + } + if workers <= 0 { + workers = 1 + } + + jobs := make(chan int, len(queries)) + for i := range queries { + jobs <- i + } + 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 + } + if heartbeat != nil { + if err := heartbeat(); err != nil { + return + } + } + query := queries[i] + items := searchBraveQuery(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.Wait() + return state.out +} + +func shouldStopCollect(out []BraveSource, cfg BraveCollectConfig) bool { + if len(out) >= cfg.MaxSourcesCap { + return true + } + return len(out) >= cfg.MinSourcesBeforeStop && uniqueSourceCount(out) >= cfg.MinSourcesBeforeStop +} + +func searchBraveQuery( + ctx context.Context, + client *libbrave.Client, + locale BraveSearchLocale, + query string, + limit int, +) []BraveSource { + res, _ := client.Search(ctx, libbrave.SearchOptions{ + Query: query, + Limit: limit, + Mode: libbrave.ModeKnowledgeExpand, + Country: locale.Country, + SearchLang: locale.SearchLang, + }) + items := make([]BraveSource, 0, len(res.Results)) + for _, item := range res.Results { + url := strings.TrimSpace(item.URL) + if url == "" { + continue + } + items = append(items, BraveSource{ + Query: query, + Snippet: item.Snippet, + URL: url, + Title: item.Title, + }) + } + return items +} + +func appendBraveResults(out *[]BraveSource, seenURL map[string]struct{}, query string, items []BraveSource) { + for _, item := range items { + url := strings.TrimSpace(item.URL) + if url == "" { + continue + } + if _, ok := seenURL[url]; ok { + continue + } + seenURL[url] = struct{}{} + if item.Query == "" { + item.Query = query + } + *out = append(*out, item) + } +} + +func MergeBraveSources(chunks ...[]BraveSource) []BraveSource { + seen := map[string]struct{}{} + out := make([]BraveSource, 0) + for _, chunk := range chunks { + for _, item := range chunk { + url := strings.TrimSpace(item.URL) + if url == "" { + continue + } + if _, ok := seen[url]; ok { + continue + } + seen[url] = struct{}{} + out = append(out, item) + } + } + return out +} + +func uniqueSourceCount(items []BraveSource) int { + seen := map[string]struct{}{} + for _, item := range items { + url := strings.TrimSpace(item.URL) + if url == "" { + continue + } + seen[url] = struct{}{} + } + return len(seen) +} diff --git a/haixun-backend/internal/library/knowledge/brave_collect_test.go b/haixun-backend/internal/library/knowledge/brave_collect_test.go new file mode 100644 index 0000000..51324a5 --- /dev/null +++ b/haixun-backend/internal/library/knowledge/brave_collect_test.go @@ -0,0 +1,104 @@ +package knowledge + +import "testing" + +func TestUniqueSourceCount(t *testing.T) { + count := uniqueSourceCount([]BraveSource{ + {URL: "https://a.example"}, + {URL: "https://a.example"}, + {URL: "https://b.example"}, + {URL: ""}, + }) + if count != 2 { + t.Fatalf("expected 2 unique urls, got %d", count) + } +} + +func TestPlanQueriesHybridBudget(t *testing.T) { + queries := PlanQueries(PlanInput{ + Seed: "敏感肌", + TargetAudience: "孕婦", + Questions: []string{"q1", "q2", "q3", "q4"}, + Pillars: []string{"p1", "p2", "p3"}, + L1Labels: []string{"a", "b", "c", "d"}, + Strategy: ExpandStrategyHybrid, + }) + if len(queries) > 4 { + t.Fatalf("hybrid should cap at 4 queries, got %d: %v", len(queries), queries) + } + if len(queries) == 0 { + t.Fatal("expected some queries") + } +} + +func TestPlanQueriesPrioritizesPatrolKeywords(t *testing.T) { + queries := PlanQueries(PlanInput{ + Seed: "敏感肌", + PatrolKeywords: []string{"化療 沐浴乳", "無香沐浴乳 推薦"}, + Questions: []string{"很長的受眾提問一", "很長的受眾提問二"}, + Strategy: ExpandStrategyBrave, + }) + if len(queries) == 0 { + t.Fatal("expected queries") + } + if queries[0] != "化療 沐浴乳" { + t.Fatalf("expected patrol keyword first, got %q", queries[0]) + } +} + +func TestQueriesExcept(t *testing.T) { + remaining := QueriesExcept( + []string{"a", "b", "c", "d"}, + []string{"a", "c"}, + ) + if len(remaining) != 2 || remaining[0] != "b" || remaining[1] != "d" { + t.Fatalf("unexpected remaining: %v", remaining) + } +} + +func TestPlanBootstrapQueriesSkipsResearchMapFields(t *testing.T) { + full := PlanQueries(PlanInput{ + Seed: "敏感肌", + Questions: []string{"q1"}, + Pillars: []string{"p1"}, + Strategy: ExpandStrategyBrave, + }) + bootstrap := PlanBootstrapQueries(PlanInput{ + Seed: "敏感肌", + Questions: []string{"q1"}, + Pillars: []string{"p1"}, + Strategy: ExpandStrategyBrave, + }) + if len(bootstrap) == 0 { + t.Fatal("expected bootstrap queries") + } + for _, q := range bootstrap { + if q == "q1" || q == "p1 請問" { + t.Fatalf("bootstrap should not include research map query %q", q) + } + } + if len(full) <= len(bootstrap) { + t.Fatalf("full plan should include more queries than bootstrap: full=%v bootstrap=%v", full, bootstrap) + } +} + +func TestMergeBraveSourcesDedupesURLs(t *testing.T) { + merged := MergeBraveSources( + []BraveSource{{URL: "https://a.example", Query: "q1"}}, + []BraveSource{{URL: "https://a.example", Query: "q2"}, {URL: "https://b.example", Query: "q3"}}, + ) + if len(merged) != 2 { + t.Fatalf("expected 2 merged sources, got %d", len(merged)) + } +} + +func TestSupplementalQueriesSkippedForHybrid(t *testing.T) { + queries := PlanQueries(PlanInput{ + Seed: "敏感肌", + Supplemental: true, + Strategy: ExpandStrategyHybrid, + }) + if len(queries) != 0 { + t.Fatalf("hybrid supplemental brave should be empty, got %v", queries) + } +} diff --git a/haixun-backend/internal/library/knowledge/derive.go b/haixun-backend/internal/library/knowledge/derive.go deleted file mode 100644 index 172e351..0000000 --- a/haixun-backend/internal/library/knowledge/derive.go +++ /dev/null @@ -1,67 +0,0 @@ -package knowledge - -import ( - "strings" - "unicode/utf8" -) - -const maxDerivedTagRunes = 8 - -func DeriveSearchTagsFromGraph(graph *Graph) { - if graph == nil { - return - } - for i := range graph.Nodes { - graph.Nodes[i].DerivedTags = deriveNodeTags(graph.Nodes[i]) - } - graph.PainTagCount = CountPainTagCandidates(graph.Nodes) -} - -func deriveNodeTags(node Node) DerivedTags { - label := strings.TrimSpace(node.Label) - if label == "" { - return DerivedTags{} - } - relevance := []string{clampTag(label)} - recency := []string{} - if IsPainNode(node) { - if q := BuildRecencyQuery(label); q != "" { - recency = append(recency, clampTag(q)) - } - if node.Layer >= 1 { - recency = append(recency, clampTag(label+" 推薦")) - } - } - relevance = uniqueTags(relevance) - recency = uniqueTags(recency) - return DerivedTags{Relevance: relevance, Recency: recency} -} - -func clampTag(text string) string { - text = strings.TrimSpace(text) - if text == "" { - return "" - } - if utf8.RuneCountInString(text) <= maxDerivedTagRunes { - return text - } - runes := []rune(text) - return string(runes[:maxDerivedTagRunes]) -} - -func uniqueTags(items []string) []string { - seen := map[string]struct{}{} - out := make([]string, 0, len(items)) - for _, item := range items { - item = strings.TrimSpace(item) - if item == "" { - continue - } - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} - out = append(out, item) - } - return out -} diff --git a/haixun-backend/internal/library/knowledge/expand_strategy.go b/haixun-backend/internal/library/knowledge/expand_strategy.go new file mode 100644 index 0000000..3007e2f --- /dev/null +++ b/haixun-backend/internal/library/knowledge/expand_strategy.go @@ -0,0 +1,35 @@ +package knowledge + +import "strings" + +type ExpandStrategy string + +const ( + ExpandStrategyBrave ExpandStrategy = "brave" + ExpandStrategyLLM ExpandStrategy = "llm" + ExpandStrategyHybrid ExpandStrategy = "hybrid" +) + +func ParseExpandStrategy(raw string) ExpandStrategy { + switch strings.ToLower(strings.TrimSpace(raw)) { + case string(ExpandStrategyLLM): + return ExpandStrategyLLM + case string(ExpandStrategyHybrid): + return ExpandStrategyHybrid + default: + return ExpandStrategyBrave + } +} + +func (s ExpandStrategy) RequiresBrave() bool { + return s == ExpandStrategyBrave || s == ExpandStrategyHybrid +} + +// UsesSupplementalBrave 廣度補充是否再打第二輪 Brave(hybrid 改由 LLM 補廣度以省 API)。 +func (s ExpandStrategy) UsesSupplementalBrave() bool { + return s == ExpandStrategyBrave +} + +func (s ExpandStrategy) String() string { + return string(s) +} diff --git a/haixun-backend/internal/library/knowledge/expand_strategy_test.go b/haixun-backend/internal/library/knowledge/expand_strategy_test.go new file mode 100644 index 0000000..6cc23cb --- /dev/null +++ b/haixun-backend/internal/library/knowledge/expand_strategy_test.go @@ -0,0 +1,40 @@ +package knowledge + +import "testing" + +func TestExpandStrategyUsesSupplementalBrave(t *testing.T) { + if !ExpandStrategyBrave.UsesSupplementalBrave() { + t.Fatal("brave should use supplemental brave") + } + if ExpandStrategyHybrid.UsesSupplementalBrave() { + t.Fatal("hybrid should skip supplemental brave") + } + if ExpandStrategyLLM.UsesSupplementalBrave() { + t.Fatal("llm should skip supplemental brave") + } +} + +func TestParseExpandStrategy(t *testing.T) { + cases := map[string]ExpandStrategy{ + "": ExpandStrategyBrave, + "brave": ExpandStrategyBrave, + "BRAVE": ExpandStrategyBrave, + "llm": ExpandStrategyLLM, + "hybrid": ExpandStrategyHybrid, + "unknown": ExpandStrategyBrave, + } + for raw, want := range cases { + if got := ParseExpandStrategy(raw); got != want { + t.Fatalf("ParseExpandStrategy(%q) = %q, want %q", raw, got, want) + } + } +} + +func TestExpandStrategyRequiresBrave(t *testing.T) { + if !ExpandStrategyBrave.RequiresBrave() || !ExpandStrategyHybrid.RequiresBrave() { + t.Fatal("brave/hybrid should require brave") + } + if ExpandStrategyLLM.RequiresBrave() { + t.Fatal("llm should not require brave") + } +} diff --git a/haixun-backend/internal/library/knowledge/graph.go b/haixun-backend/internal/library/knowledge/graph.go index 89bc1b1..bacb474 100644 --- a/haixun-backend/internal/library/knowledge/graph.go +++ b/haixun-backend/internal/library/knowledge/graph.go @@ -20,9 +20,11 @@ type Node struct { Type string `json:"type"` // core | cause | symptom | mechanism Layer int `json:"layer"` Relation string `json:"relation,omitempty"` - PlacementValue string `json:"placementValue,omitempty"` // high | medium | low + PlacementValue string `json:"placementValue,omitempty"` // 為何與產品置入相關(完整句子) ProductFitScore int `json:"productFitScore"` SelectedForScan bool `json:"selectedForScan"` + PatrolRelevance []string `json:"patrolRelevance,omitempty"` + PatrolRecency []string `json:"patrolRecency,omitempty"` Evidence []Evidence `json:"evidence"` DerivedTags DerivedTags `json:"derivedTags"` } diff --git a/haixun-backend/internal/library/knowledge/patrol_input.go b/haixun-backend/internal/library/knowledge/patrol_input.go new file mode 100644 index 0000000..5acafaf --- /dev/null +++ b/haixun-backend/internal/library/knowledge/patrol_input.go @@ -0,0 +1,148 @@ +package knowledge + +import ( + "encoding/json" + "strings" + + branddomain "haixun-backend/internal/model/brand/domain/usecase" +) + +// PatrolTagInput grounds海巡 tag in研究地圖與品牌/產品輸入。 +type PatrolTagInput struct { + BrandName string + ProductName string + ProductFeatures string + AudienceSummary string + MatchTags []string + Questions []string + Pillars []string + PatrolKeywords []string +} + +type productContextFields struct { + Brand string `json:"brand"` + Product string `json:"product"` + Features string `json:"features"` +} + +func parseProductContextFields(raw string) productContextFields { + raw = strings.TrimSpace(raw) + if raw == "" { + return productContextFields{} + } + var parsed productContextFields + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return productContextFields{Features: raw} + } + parsed.Brand = strings.TrimSpace(parsed.Brand) + parsed.Product = strings.TrimSpace(parsed.Product) + parsed.Features = strings.TrimSpace(parsed.Features) + return parsed +} + +func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief string) PatrolTagInput { + if brand == nil { + return PatrolTagInput{} + } + fields := parseProductContextFields(brand.ProductContext) + productName := productLabelFromBrand(brand) + if productName == "" { + productName = fields.Product + } + brandName := strings.TrimSpace(brand.DisplayName) + if brandName == "" { + brandName = fields.Brand + } + features := fields.Features + if features == "" { + features = strings.TrimSpace(productBrief) + } + if features == "" { + features = strings.TrimSpace(brand.ProductBrief) + } + return PatrolTagInput{ + BrandName: brandName, + ProductName: productName, + ProductFeatures: features, + AudienceSummary: strings.TrimSpace(brand.ResearchMap.AudienceSummary), + MatchTags: matchTagsFromBrand(brand), + Questions: append([]string{}, brand.ResearchMap.Questions...), + Pillars: append([]string{}, brand.ResearchMap.Pillars...), + PatrolKeywords: append([]string{}, brand.ResearchMap.PatrolKeywords...), + } +} + +func productLabelFromBrand(brand *branddomain.BrandSummary) string { + if brand == nil { + return "" + } + productID := strings.TrimSpace(brand.ProductID) + if productID == "" { + return "" + } + for _, item := range brand.Products { + if item.ID == productID { + label := strings.TrimSpace(item.Label) + if label != "" { + return label + } + return parseProductContextFields(item.ProductContext).Product + } + } + return "" +} + +func matchTagsFromBrand(brand *branddomain.BrandSummary) []string { + if brand == nil { + return nil + } + productID := strings.TrimSpace(brand.ProductID) + seen := map[string]struct{}{} + out := []string{} + add := func(tag string) { + tag = strings.TrimSpace(tag) + if tag == "" { + return + } + if _, ok := seen[tag]; ok { + return + } + seen[tag] = struct{}{} + out = append(out, tag) + } + for _, item := range brand.Products { + if productID != "" && item.ID != productID { + continue + } + for _, tag := range item.MatchTags { + add(tag) + } + if productID != "" { + break + } + } + return out +} + +func (in PatrolTagInput) HasResearchMap() bool { + return len(in.Questions) > 0 || len(in.Pillars) > 0 || strings.TrimSpace(in.AudienceSummary) != "" +} + +func (in PatrolTagInput) HasProductContext() bool { + return strings.TrimSpace(in.ProductName) != "" || + strings.TrimSpace(in.ProductFeatures) != "" || + len(in.MatchTags) > 0 +} + +func productCategoryHint(productName, features string) string { + combined := productName + " " + features + for _, hint := range []string{ + "沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", "洗面奶", "乳液", "精華", + "防曬", "卸妝", "潔面", "身體乳", "洗手乳", "清潔", + } { + if strings.Contains(combined, hint) { + return hint + } + } + return "" +} diff --git a/haixun-backend/internal/library/knowledge/patrol_phrase.go b/haixun-backend/internal/library/knowledge/patrol_phrase.go new file mode 100644 index 0000000..b4f7308 --- /dev/null +++ b/haixun-backend/internal/library/knowledge/patrol_phrase.go @@ -0,0 +1,212 @@ +package knowledge + +import ( + "strings" + "unicode/utf8" +) + +const ( + patrolIntentRelevance = "relevance" + patrolIntentRecency = "recency" +) + +var patrolTopicAnchors = []string{ + "化療", "乳癌", "荷爾蒙", "標靶", "康復", "癌症", "病友", "敏感", "無香", "抗敏", + "香料", "香精", "皮膚", "乾癢", "沐浴", "洗髮", "洗面", "卸妝", "防曬", "懷孕", + "換季", "屏障", "過敏", "搔癢", "紅腫", +} + +var patrolFillers = []string{ + "要", "什麼", "嗎", "怎麼", "請問", "有人", "適合", "用的", "分享", "經驗", "挑選", + "可以", "不能", "需要", "應該", "到底", "真的", "覺得", "知道", "告訴", "請益", + "推薦嗎", "好用嗎", "用過", "想", "還是", "會不會", "是不是", "有沒有", "如何", "為什麼", +} + +// PatrolTagFromQuestion keeps research-map questions when already search-shaped. +func PatrolTagFromQuestion(raw string) string { + raw = strings.TrimSpace(raw) + raw = strings.Join(strings.Fields(raw), " ") + if raw == "" { + return "" + } + runes := utf8.RuneCountInString(raw) + if runes >= minPatrolTagRunes && runes <= maxPatrolTagRunes { + if looksLikeThreadsSearch(raw) { + return raw + } + if runes >= 8 && (strings.Contains(raw, " ") || productCategoryHint(raw, "") != "") { + phrase := ensurePatrolIntent(raw, patrolIntentRelevance) + if utf8.RuneCountInString(phrase) <= maxPatrolTagRunes && !isMechanicalTag(phrase) { + return phrase + } + } + } + return humanizePatrolPhrase(raw, patrolIntentRelevance) +} + +// PatrolTagFromPillar compresses pillar phrases but keeps more context than generic labels. +func PatrolTagFromPillar(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + runes := utf8.RuneCountInString(raw) + if runes >= minPatrolTagRunes && runes <= maxPatrolTagRunes && strings.Contains(raw, " ") { + return ensurePatrolIntent(raw, patrolIntentRelevance) + } + return humanizePatrolPhrase(raw, patrolIntentRelevance) +} + +func humanizePatrolPhrase(raw, intent string) string { + raw = strings.TrimSpace(raw) + raw = strings.Join(strings.Fields(raw), " ") + if raw == "" { + return "" + } + + runes := utf8.RuneCountInString(raw) + if runes <= maxPatrolTagRunes && runes >= minPatrolTagRunes && looksLikeThreadsSearch(raw) { + return raw + } + + phrase := compressPatrolKeywords(raw) + if phrase == "" { + phrase = truncateRunes(raw, maxPatrolTagRunes) + } + phrase = ensurePatrolIntent(phrase, intent) + if utf8.RuneCountInString(phrase) > maxPatrolTagRunes { + phrase = truncateRunes(phrase, maxPatrolTagRunes) + } + if utf8.RuneCountInString(phrase) < minPatrolTagRunes { + return "" + } + if isMechanicalTag(phrase) { + return "" + } + return phrase +} + +func looksLikeThreadsSearch(text string) bool { + if strings.Contains(text, " ") { + return true + } + for _, suffix := range []string{"推薦", "請問", "怎麼辦", "好用嗎", "有人", "求助"} { + if strings.Contains(text, suffix) { + return true + } + } + return false +} + +func compressPatrolKeywords(text string) string { + category := productCategoryHint(text, "") + anchors := []string{} + seen := map[string]struct{}{} + for _, anchor := range patrolTopicAnchors { + if !strings.Contains(text, anchor) { + continue + } + if _, ok := seen[anchor]; ok { + continue + } + seen[anchor] = struct{}{} + anchors = append(anchors, anchor) + if len(anchors) >= 2 { + break + } + } + + parts := append([]string{}, anchors...) + if category != "" { + parts = append(parts, category) + } + if len(parts) == 0 { + for _, chunk := range splitPatrolChunks(text) { + if isPatrolFiller(chunk) { + continue + } + parts = append(parts, chunk) + if len(parts) >= 2 { + break + } + } + } + if len(parts) == 0 { + return "" + } + phrase := strings.Join(parts, " ") + if utf8.RuneCountInString(phrase) > maxPatrolTagRunes { + return truncateRunes(phrase, maxPatrolTagRunes) + } + return phrase +} + +func splitPatrolChunks(text string) []string { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + if strings.Contains(text, " ") { + return strings.Fields(text) + } + runes := []rune(text) + if len(runes) <= 6 { + return []string{text} + } + // Long continuous Chinese: take leading topic chunk + trailing product-ish chunk. + head := string(runes[:minInt(4, len(runes))]) + tail := string(runes[maxInt(0, len(runes)-4):]) + if head == tail { + return []string{head} + } + return []string{head, tail} +} + +func ensurePatrolIntent(phrase, intent string) string { + phrase = strings.TrimSpace(phrase) + if phrase == "" { + return "" + } + if strings.ContainsAny(phrase, "推薦請問怎麼辦好用嗎有人求助") { + return phrase + } + suffix := " 推薦" + if intent == patrolIntentRecency { + suffix = " 請問" + } + if utf8.RuneCountInString(phrase+suffix) <= maxPatrolTagRunes { + return phrase + suffix + } + return phrase +} + +func isPatrolFiller(chunk string) bool { + chunk = strings.TrimSpace(chunk) + if chunk == "" || utf8.RuneCountInString(chunk) < 2 { + return true + } + for _, filler := range patrolFillers { + if chunk == filler { + return true + } + } + return false +} + +func truncateRunes(text string, max int) string { + runes := []rune(strings.TrimSpace(text)) + if len(runes) <= max { + return string(runes) + } + return string(runes[:max]) +} + +func maxInt(values ...int) int { + max := values[0] + for _, v := range values[1:] { + if v > max { + max = v + } + } + return max +} diff --git a/haixun-backend/internal/library/knowledge/patrol_phrase_test.go b/haixun-backend/internal/library/knowledge/patrol_phrase_test.go new file mode 100644 index 0000000..9f942d2 --- /dev/null +++ b/haixun-backend/internal/library/knowledge/patrol_phrase_test.go @@ -0,0 +1,20 @@ +package knowledge + +import "testing" + +func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) { + got := PatrolTagFromQuestion("化療後皮膚敏感要換什麼沐浴乳") + if got != "化療後皮膚敏感要換什麼沐浴乳" { + t.Fatalf("expected preserved question, got %q", got) + } +} + +func TestPatrolTagFromQuestionCompressesGenericLabel(t *testing.T) { + got := PatrolTagFromQuestion("敏感肌保養") + if got == "敏感肌保養" { + t.Fatal("expected compression for generic short label") + } + if got == "" { + t.Fatal("expected non-empty tag") + } +} diff --git a/haixun-backend/internal/library/knowledge/patrol_rank.go b/haixun-backend/internal/library/knowledge/patrol_rank.go new file mode 100644 index 0000000..9a8dc1e --- /dev/null +++ b/haixun-backend/internal/library/knowledge/patrol_rank.go @@ -0,0 +1,264 @@ +package knowledge + +import ( + "sort" + "strings" +) + +const MaxTopPatrolTags = 8 + +type PatrolTagCandidate struct { + Tag string + Score int + Reason string + Intent string +} + +func CollectPatrolTagsFromGraph(in PatrolTagInput, nodes []Node) []string { + return SelectTopPatrolTags(BuildPatrolCandidates(in, nodes), MaxTopPatrolTags) +} + +func BuildPatrolCandidates(in PatrolTagInput, nodes []Node) []PatrolTagCandidate { + out := []PatrolTagCandidate{} + for i, tag := range SanitizePatrolKeywordList(in.PatrolKeywords) { + addPatrolCandidate(&out, tag, 120-i, "手動精選", patrolIntentRelevance) + } + for i, q := range in.Questions { + addPatrolCandidate(&out, PatrolTagFromQuestion(q), scoreQuestion(i)+12, "受眾提問", patrolIntentRelevance) + } + for i, pillar := range in.Pillars { + addPatrolCandidate(&out, PatrolTagFromPillar(pillar), scorePillar(i)+6, "內容支柱", patrolIntentRelevance) + } + for i, matchTag := range in.MatchTags { + addPatrolCandidate(&out, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance) + } + if in.ProductName != "" { + addPatrolCandidate(&out, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance) + } + for _, node := range nodes { + if !IsPainNode(node) && node.Layer > 1 { + continue + } + nodeScore := scoreNode(node) + for _, tag := range node.DerivedTags.Relevance { + addPatrolCandidate(&out, tag, nodeScore, nodePatrolReason(node), patrolIntentRelevance) + } + for _, tag := range node.DerivedTags.Recency { + addPatrolCandidate(&out, tag, nodeScore-3, nodePatrolReason(node), patrolIntentRecency) + } + } + return out +} + +func SelectTopPatrolTags(candidates []PatrolTagCandidate, limit int) []string { + if limit <= 0 { + return nil + } + picked := selectTopPatrolCandidates(candidates, limit) + out := make([]string, 0, len(picked)) + for _, item := range picked { + out = append(out, item.Tag) + } + return out +} + +func selectBestDerivedTags(candidates []PatrolTagCandidate, relLimit, recLimit int) DerivedTags { + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Score == candidates[j].Score { + return candidates[i].Tag < candidates[j].Tag + } + return candidates[i].Score > candidates[j].Score + }) + seenRelKey := map[string]struct{}{} + seenRecTag := map[string]struct{}{} + relevance := []string{} + recency := []string{} + for _, item := range candidates { + if item.Intent == patrolIntentRecency { + continue + } + key := patrolTagDedupeKey(item.Tag) + if _, ok := seenRelKey[key]; ok { + continue + } + seenRelKey[key] = struct{}{} + relevance = append(relevance, item.Tag) + if len(relevance) >= relLimit { + break + } + } + for _, item := range candidates { + if item.Intent != patrolIntentRecency { + continue + } + if _, ok := seenRecTag[item.Tag]; ok { + continue + } + seenRecTag[item.Tag] = struct{}{} + recency = append(recency, item.Tag) + if len(recency) >= recLimit { + break + } + } + if len(recency) < recLimit && len(relevance) > 0 { + for _, rel := range relevance { + alt := patrolRecencyFallback(rel) + if alt == "" || containsString(relevance, alt) { + continue + } + if _, ok := seenRecTag[alt]; ok { + continue + } + recency = append(recency, alt) + if len(recency) >= recLimit { + break + } + } + } + return DerivedTags{ + Relevance: capTags(uniqueTags(relevance), relLimit), + Recency: capTags(uniqueTags(recency), recLimit), + } +} + +func selectTopPatrolCandidates(candidates []PatrolTagCandidate, limit int) []PatrolTagCandidate { + if limit <= 0 || len(candidates) == 0 { + return nil + } + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Score == candidates[j].Score { + return candidates[i].Tag < candidates[j].Tag + } + return candidates[i].Score > candidates[j].Score + }) + seenTag := map[string]struct{}{} + seenKey := map[string]struct{}{} + out := make([]PatrolTagCandidate, 0, limit) + for _, item := range candidates { + tag := strings.TrimSpace(item.Tag) + if tag == "" { + continue + } + key := patrolTagDedupeKey(tag) + if _, ok := seenTag[tag]; ok { + continue + } + if _, ok := seenKey[key]; ok { + continue + } + seenTag[tag] = struct{}{} + seenKey[key] = struct{}{} + out = append(out, PatrolTagCandidate{ + Tag: tag, + Score: item.Score, + Reason: item.Reason, + Intent: item.Intent, + }) + if len(out) >= limit { + break + } + } + return out +} + +func addPatrolCandidate(out *[]PatrolTagCandidate, tag string, score int, reason, intent string) { + tag = strings.TrimSpace(tag) + if tag == "" || score <= 0 { + return + } + *out = append(*out, PatrolTagCandidate{ + Tag: tag, + Score: score, + Reason: reason, + Intent: intent, + }) +} + +func scoreQuestion(index int) int { + score := 100 - index*4 + if score < 70 { + return 70 + } + return score +} + +func scorePillar(index int) int { + score := 74 - index*3 + if score < 55 { + return 55 + } + return score +} + +func scoreMatchTag(index int) int { + score := 90 - index*4 + if score < 70 { + return 70 + } + return score +} + +func scoreProduct() int { + return 58 +} + +func scoreNode(node Node) int { + base := 48 + switch node.Layer { + case 0: + base = 82 + case 1: + base = 68 + case 2: + base = 52 + } + if IsPainNode(node) { + base += 8 + } + if node.ProductFitScore > 0 { + base += node.ProductFitScore / 6 + } + return base +} + +func nodePatrolReason(node Node) string { + switch node.Layer { + case 0: + return "核心痛點" + case 1: + return "高相關延伸" + default: + return "周邊情境" + } +} + +func patrolTagDedupeKey(tag string) string { + tag = strings.TrimSpace(tag) + for _, suffix := range []string{" 推薦", " 請問", " 怎麼辦", " 好用嗎", " 有人用過嗎", " 有推薦嗎"} { + if strings.HasSuffix(tag, suffix) { + tag = strings.TrimSuffix(tag, suffix) + break + } + } + return tag +} + +func patrolRecencyFallback(relevanceTag string) string { + alt := patrolTagFromSource(relevanceTag, patrolIntentRecency) + if alt != "" && alt != relevanceTag { + return alt + } + if strings.Contains(relevanceTag, "請問") { + return "" + } + return patrolTagFromSource(relevanceTag+" 請問", patrolIntentRecency) +} + +func containsString(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} diff --git a/haixun-backend/internal/library/knowledge/patrol_rank_test.go b/haixun-backend/internal/library/knowledge/patrol_rank_test.go new file mode 100644 index 0000000..11533be --- /dev/null +++ b/haixun-backend/internal/library/knowledge/patrol_rank_test.go @@ -0,0 +1,45 @@ +package knowledge + +import "testing" + +func TestSelectTopPatrolTagsLimitsAndDedupes(t *testing.T) { + tags := SelectTopPatrolTags([]PatrolTagCandidate{ + {Tag: "化療 沐浴乳", Score: 100, Reason: "受眾提問", Intent: patrolIntentRelevance}, + {Tag: "化療 沐浴乳 推薦", Score: 98, Reason: "受眾提問", Intent: patrolIntentRelevance}, + {Tag: "無香沐浴乳 推薦", Score: 90, Reason: "產品匹配", Intent: patrolIntentRelevance}, + {Tag: "乳癌 沐浴乳", Score: 88, Reason: "內容支柱", Intent: patrolIntentRelevance}, + {Tag: "荷爾蒙 敏感", Score: 80, Reason: "內容支柱", Intent: patrolIntentRelevance}, + {Tag: "化療 皮膚", Score: 75, Reason: "高相關延伸", Intent: patrolIntentRelevance}, + {Tag: "病友 沐浴乳", Score: 70, Reason: "周邊情境", Intent: patrolIntentRelevance}, + {Tag: "抗敏 沐浴乳", Score: 65, Reason: "置入產品", Intent: patrolIntentRelevance}, + {Tag: "香精 過敏", Score: 60, Reason: "周邊情境", Intent: patrolIntentRelevance}, + }, MaxTopPatrolTags) + if len(tags) != MaxTopPatrolTags { + t.Fatalf("expected %d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags) + } + if tags[0] != "化療 沐浴乳" { + t.Fatalf("expected highest score first, got %q", tags[0]) + } +} + +func TestCollectPatrolTagsCapsOutput(t *testing.T) { + tags := CollectPatrolTags(PatrolTagInput{ + ProductName: "抗敏無香沐浴露", + MatchTags: []string{"化療", "無香沐浴乳", "乳癌"}, + Questions: []string{ + "化療後皮膚敏感要換什麼沐浴乳", + "乳癌治療中不能用有香味的沐浴乳嗎", + "癌症病人適合用的無香沐浴乳推薦", + "荷爾蒙治療皮膚乾癢怎麼挑沐浴乳", + }, + Pillars: []string{ + "化療皮膚敏感無香沐浴乳", + "乳癌病友沐浴用品挑選", + "荷爾蒙治療對香味敏感", + "癌症康復後換清潔品牌", + }, + }) + if len(tags) > MaxTopPatrolTags { + t.Fatalf("expected <=%d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags) + } +} diff --git a/haixun-backend/internal/library/knowledge/patrol_tags.go b/haixun-backend/internal/library/knowledge/patrol_tags.go new file mode 100644 index 0000000..08c2ea2 --- /dev/null +++ b/haixun-backend/internal/library/knowledge/patrol_tags.go @@ -0,0 +1,249 @@ +package knowledge + +import ( + "strings" + "unicode/utf8" +) + +const ( + minPatrolTagRunes = 4 + maxPatrolTagRunes = 16 + maxRelevanceTags = 1 + maxRecencyTags = 1 +) + +func DeriveSearchTagsFromGraph(graph *Graph, in PatrolTagInput) { + if graph == nil { + return + } + for i := range graph.Nodes { + graph.Nodes[i].DerivedTags = derivePatrolTagsForNode(graph.Nodes[i], in) + } + graph.PainTagCount = CountPainTagCandidates(graph.Nodes) +} + +// CollectPatrolTags returns精選海巡搜尋句(研究地圖 + 產品 + 高相關節點)。 +func CollectPatrolTags(in PatrolTagInput) []string { + return CollectPatrolTagsFromGraph(in, nil) +} + +// DerivePatrolTagsForNode resolves a single node's patrol tags. +func DerivePatrolTagsForNode(node Node, in PatrolTagInput) DerivedTags { + return derivePatrolTagsForNode(node, in) +} + +func derivePatrolTagsForNode(node Node, in PatrolTagInput) DerivedTags { + presetRel := sanitizePatrolTags(node.PatrolRelevance) + presetRec := sanitizePatrolTags(node.PatrolRecency) + if len(presetRel) > 0 || len(presetRec) > 0 { + return DerivedTags{ + Relevance: capTags(presetRel, maxRelevanceTags), + Recency: capTags(presetRec, maxRecencyTags), + } + } + if in.HasResearchMap() || in.HasProductContext() { + derived := deriveFromResearchMapAndProduct(node, in) + if len(derived.Relevance) > 0 || len(derived.Recency) > 0 { + return derived + } + } + return deriveFallbackPatrolTags(node) +} + +func deriveFromResearchMapAndProduct(node Node, in PatrolTagInput) DerivedTags { + candidates := []PatrolTagCandidate{} + matches := isCoreNode(node) + + for i, q := range in.Questions { + if matches || nodeMatchesResearchText(node, q) { + addPatrolCandidate(&candidates, PatrolTagFromQuestion(q), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance) + addPatrolCandidate(&candidates, patrolTagFromSource(q, patrolIntentRecency), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency) + } + } + for i, pillar := range in.Pillars { + if matches || nodeMatchesResearchText(node, pillar) { + addPatrolCandidate(&candidates, PatrolTagFromPillar(pillar), scorePillar(i)+4, "內容支柱", patrolIntentRelevance) + } + } + for i, matchTag := range in.MatchTags { + if matches || nodeMatchesResearchText(node, matchTag) { + addPatrolCandidate(&candidates, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance) + } + } + if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName)) { + addPatrolCandidate(&candidates, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance) + } + + if len(candidates) == 0 && IsPainNode(node) { + for i, q := range in.Questions { + if i >= 2 { + break + } + addPatrolCandidate(&candidates, PatrolTagFromQuestion(q), scoreQuestion(i)+8, "受眾提問", patrolIntentRelevance) + } + } + if len(candidates) == 0 && IsPainNode(node) { + addPatrolCandidate(&candidates, patrolTagFromSource(in.ProductName, patrolIntentRelevance), scoreProduct(), "置入產品", patrolIntentRelevance) + for i, matchTag := range in.MatchTags { + if i >= 1 { + break + } + addPatrolCandidate(&candidates, patrolTagFromSource(matchTag, patrolIntentRelevance), scoreMatchTag(i), "產品匹配", patrolIntentRelevance) + } + } + + return selectBestDerivedTags(candidates, maxRelevanceTags, maxRecencyTags) +} + +func patrolTagFromSource(raw, intent string) string { + tag := humanizePatrolPhrase(raw, intent) + if tag == "" || isMechanicalTag(tag) { + return "" + } + return tag +} + +func isCoreNode(node Node) bool { + return node.Layer == 0 || strings.EqualFold(strings.TrimSpace(node.Type), "core") +} + +func nodeMatchesResearchText(node Node, text string) bool { + label := strings.TrimSpace(node.Label) + text = strings.TrimSpace(text) + if label == "" || text == "" { + return false + } + if label == text || strings.Contains(text, label) || strings.Contains(label, text) { + return true + } + return sharesMeaningfulSubstring(label, text) +} + +func deriveFallbackPatrolTags(node Node) DerivedTags { + label := strings.TrimSpace(node.Label) + if label == "" || !IsPainNode(node) { + return DerivedTags{} + } + relevance := []string{patrolTagFromSource(label, patrolIntentRelevance)} + recency := []string{patrolTagFromSource(label, patrolIntentRecency)} + return DerivedTags{ + Relevance: capTags(uniqueTags(relevance), maxRelevanceTags), + Recency: capTags(uniqueTags(recency), maxRecencyTags), + } +} + +func capTags(items []string, max int) []string { + if max <= 0 || len(items) <= max { + return items + } + return items[:max] +} + +func sharesMeaningfulSubstring(a, b string) bool { + ar := []rune(a) + br := []rune(b) + if len(ar) < 2 || len(br) < 2 { + return false + } + for size := minInt(len(ar), len(br), 4); size >= 2; size-- { + for i := 0; i+size <= len(ar); i++ { + chunk := string(ar[i : i+size]) + if strings.Contains(b, chunk) { + return true + } + } + } + return false +} + +func minInt(values ...int) int { + if len(values) == 0 { + return 0 + } + min := values[0] + for _, v := range values[1:] { + if v < min { + min = v + } + } + return min +} + +// NormalizePatrolKeywordList keeps user-edited patrol keywords as typed (trim + dedupe only). +func NormalizePatrolKeywordList(items []string) []string { + out := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + runes := utf8.RuneCountInString(item) + if runes < 2 || runes > maxPatrolTagRunes { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + if len(out) >= MaxTopPatrolTags { + break + } + } + return out +} + +// SanitizePatrolKeywordList normalizes auto-derived海巡 tag(圖譜 / AI 產生)。 +func SanitizePatrolKeywordList(items []string) []string { + out := sanitizePatrolTags(items) + if len(out) > MaxTopPatrolTags { + return out[:MaxTopPatrolTags] + } + return out +} + +func sanitizePatrolTags(items []string) []string { + out := make([]string, 0, len(items)) + for _, item := range items { + if tag := sanitizePatrolTag(item); tag != "" { + out = append(out, tag) + } + } + return uniqueTags(out) +} + +func sanitizePatrolTag(text string) string { + text = humanizePatrolPhrase(text, patrolIntentRelevance) + if text == "" { + return "" + } + if isMechanicalTag(text) { + return "" + } + return text +} + +func isMechanicalTag(text string) bool { + if strings.Contains(text, " ") { + return false + } + return utf8.RuneCountInString(text) <= 4 +} + +func uniqueTags(items []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + return out +} diff --git a/haixun-backend/internal/library/knowledge/patrol_tags_test.go b/haixun-backend/internal/library/knowledge/patrol_tags_test.go new file mode 100644 index 0000000..94b1a69 --- /dev/null +++ b/haixun-backend/internal/library/knowledge/patrol_tags_test.go @@ -0,0 +1,13 @@ +package knowledge + +import "testing" + +func TestNormalizePatrolKeywordListPreservesUserInput(t *testing.T) { + got := NormalizePatrolKeywordList([]string{" 化療 沐浴乳 ", "化療 沐浴乳", "敏感肌"}) + if len(got) != 2 { + t.Fatalf("expected 2 keywords, got %v", got) + } + if got[0] != "化療 沐浴乳" || got[1] != "敏感肌" { + t.Fatalf("unexpected normalize result: %v", got) + } +} diff --git a/haixun-backend/internal/library/knowledge/quality.go b/haixun-backend/internal/library/knowledge/quality.go new file mode 100644 index 0000000..23a6150 --- /dev/null +++ b/haixun-backend/internal/library/knowledge/quality.go @@ -0,0 +1,62 @@ +package knowledge + +import ( + "strconv" + "strings" + "unicode/utf8" +) + +const ( + minGraphNodes = 18 + minLayer1Nodes = 6 + minLayer2Nodes = 10 + minBreadthNodes = 22 + targetGraphNodes = 28 + minNodeCopyRunes = 15 +) + +func GraphTooThin(g Graph) bool { + if len(g.Nodes) < minGraphNodes { + return true + } + layerCounts := map[int]int{} + thinCopy := 0 + for _, node := range g.Nodes { + layerCounts[node.Layer]++ + if utf8.RuneCountInString(strings.TrimSpace(node.Relation)) < minNodeCopyRunes || + utf8.RuneCountInString(strings.TrimSpace(node.PlacementValue)) < minNodeCopyRunes { + thinCopy++ + } + } + if layerCounts[1] < minLayer1Nodes || layerCounts[2] < minLayer2Nodes { + return true + } + if thinCopy > len(g.Nodes)/2 { + return true + } + return false +} + +func GraphNeedsBreadth(g Graph) bool { + return len(g.Nodes) < minBreadthNodes +} + +func MinBreadthGraphNodes() int { + return minBreadthNodes +} + +func KnowledgeGraphRetryUserPrompt() string { + return strings.TrimSpace(`上次產出過於簡略或節點不足。請重新產出完整 TKG JSON: +- 節點總數 **24~32 個**,L1≥8、L2≥12(廣度優先,多方向觸及) +- 每個節點的 relation 與 placementValue 各 25~55 字,寫完整句子 +- 痛點/求助類節點至少 10 個;L2 周邊情境要覆蓋多種生活場景,不可精簡或省略欄位`) +} + +func KnowledgeGraphBreadthUserPrompt(currentNodes int) string { + return strings.TrimSpace(`目前延伸知識僅 ` + strconv.Itoa(currentNodes) + ` 個節點,廣度不足。請**維持既有節點**,只追加新節點與邊: +- 至少再增加 **10~14 個**節點,總數目標 **24~32 個** +- 優先補齊 L2 周邊情境:不同治療階段、生活事件、相鄰困擾、相關品類與使用場景 +- 也要補 L1 直接相關的成因、症狀、機制,讓圖譜能觸及更多討論方向 +- 新節點的 relation 與 placementValue 必須各 25~55 字;不要 high/medium/low +- 只追加,不要刪除或覆蓋既有節點`) +} diff --git a/haixun-backend/internal/library/knowledge/quality_test.go b/haixun-backend/internal/library/knowledge/quality_test.go new file mode 100644 index 0000000..28f82fb --- /dev/null +++ b/haixun-backend/internal/library/knowledge/quality_test.go @@ -0,0 +1,40 @@ +package knowledge + +import ( + "fmt" + "testing" +) + +func TestGraphTooThin(t *testing.T) { + thin := Graph{ + Nodes: []Node{ + {Label: "a", Layer: 0, Relation: "短", PlacementValue: "短"}, + }, + } + if !GraphTooThin(thin) { + t.Fatal("expected thin graph") + } + + nodes := []Node{{Label: "core", Layer: 0, Relation: "核心種子主題的完整說明文字", PlacementValue: "核心痛點帖最常求推薦適合自然回覆"}} + for i := 0; i < 7; i++ { + nodes = append(nodes, Node{ + Label: fmt.Sprintf("l1-%d", i), + Layer: 1, + NodeKind: "pain", + Relation: "這是 L1 節點與種子詞之間的完整脈絡說明", + PlacementValue: "這類討論串常求產品推薦適合以使用經驗自然帶入", + }) + } + for i := 0; i < 11; i++ { + nodes = append(nodes, Node{ + Label: fmt.Sprintf("l2-%d", i), + Layer: 2, + NodeKind: "symptom", + Relation: "這是 L2 周邊情境與種子詞之間的完整脈絡說明", + PlacementValue: "周邊情境帖仍有置入空間可分享溫和修護經驗", + }) + } + if GraphTooThin(Graph{Nodes: nodes}) { + t.Fatal("expected rich graph") + } +} diff --git a/haixun-backend/internal/library/knowledge/queries.go b/haixun-backend/internal/library/knowledge/queries.go index a345bf7..d815079 100644 --- a/haixun-backend/internal/library/knowledge/queries.go +++ b/haixun-backend/internal/library/knowledge/queries.go @@ -10,19 +10,34 @@ import ( ) type queryConfig struct { - MaxPlanQueries int `json:"max_plan_queries"` - MaxSupplemental int `json:"max_supplemental_queries"` - MinPainTagCandidates int `json:"min_pain_tag_candidates"` - MinTotalTagCandidates int `json:"min_total_tag_candidates"` - PlanBase []string `json:"plan_base"` - PlanPeripheral []string `json:"plan_peripheral"` - PlanAudience string `json:"plan_audience"` - PlanL1Cause string `json:"plan_l1_cause"` - PlanL1Pain string `json:"plan_l1_pain"` - Supplemental []string `json:"supplemental"` - SupplementalL1 string `json:"supplemental_l1"` - RecencySuffix string `json:"recency_suffix"` - RecencyHelpMarkers string `json:"recency_help_markers"` + MaxPlanQueries int `json:"max_plan_queries"` + HybridMaxPlanQueries int `json:"hybrid_max_plan_queries"` + MaxSupplemental int `json:"max_supplemental_queries"` + HybridMaxSupplemental int `json:"hybrid_max_supplemental_queries"` + ResultsPerQuery int `json:"results_per_query"` + MinSourcesBeforeStop int `json:"min_sources_before_stop"` + MaxSourcesCap int `json:"max_sources_cap"` + BraveCollectConcurrency int `json:"brave_collect_concurrency"` + MaxPatrolKeywordQueries int `json:"max_patrol_keyword_queries"` + MaxQuestionQueries int `json:"max_question_queries"` + MaxPillarQueries int `json:"max_pillar_queries"` + MaxPlanBaseQueries int `json:"max_plan_base_queries"` + MaxPeripheralQueries int `json:"max_peripheral_queries"` + MaxL1Labels int `json:"max_l1_labels"` + MinPainTagCandidates int `json:"min_pain_tag_candidates"` + MinTotalTagCandidates int `json:"min_total_tag_candidates"` + PlanBase []string `json:"plan_base"` + PlanPeripheral []string `json:"plan_peripheral"` + PlanAudience string `json:"plan_audience"` + PlanL1Cause string `json:"plan_l1_cause"` + PlanL1Pain string `json:"plan_l1_pain"` + PlanPillar string `json:"plan_pillar"` + PlanQuestion string `json:"plan_question"` + Supplemental []string `json:"supplemental"` + SupplementalL1 string `json:"supplemental_l1"` + SupplementalPillar string `json:"supplemental_pillar"` + RecencySuffix string `json:"recency_suffix"` + RecencyHelpMarkers string `json:"recency_help_markers"` } var ( @@ -76,8 +91,12 @@ type PlanInput struct { Seed string TargetAudience string ProductBrief string + Pillars []string + Questions []string + PatrolKeywords []string L1Labels []string Supplemental bool + Strategy ExpandStrategy } func PlanQueries(in PlanInput) []string { @@ -90,64 +109,183 @@ func PlanQueries(in PlanInput) []string { return nil } if in.Supplemental { - return supplementalQueries(cfg, seed, in.L1Labels) + return supplementalQueries(cfg, in) } + return planPrimaryQueries(cfg, in) +} +func queryBudget(cfg queryConfig, strategy ExpandStrategy, supplemental bool) int { + if supplemental { + if strategy == ExpandStrategyHybrid { + if cfg.HybridMaxSupplemental > 0 { + return cfg.HybridMaxSupplemental + } + return 0 + } + max := cfg.MaxSupplemental + if max <= 0 { + return 4 + } + return max + } + if strategy == ExpandStrategyHybrid { + max := cfg.HybridMaxPlanQueries + if max <= 0 { + return 5 + } + return max + } + max := cfg.MaxPlanQueries + if max <= 0 { + return 10 + } + return max +} + +func planPrimaryQueries(cfg queryConfig, in PlanInput) []string { + seed := strings.TrimSpace(in.Seed) + budget := queryBudget(cfg, in.Strategy, false) + if budget <= 0 { + return nil + } seen := map[string]struct{}{} - out := make([]string, 0, cfg.MaxPlanQueries) - add := func(q string) { + out := make([]string, 0, budget) + add := func(q string) bool { q = strings.TrimSpace(q) if q == "" { - return + return false } if _, ok := seen[q]; ok { - return + return false } seen[q] = struct{}{} out = append(out, q) + return len(out) >= budget } vars := map[string]string{"seed": seed, "audience": strings.TrimSpace(in.TargetAudience)} - for _, tpl := range cfg.PlanBase { - add(renderQueryTemplate(tpl, vars)) - if len(out) >= cfg.MaxPlanQueries { - return capQueries(out, cfg.MaxPlanQueries) + + patrolLimit := cfg.MaxPatrolKeywordQueries + if patrolLimit <= 0 { + patrolLimit = 4 + } + for i, keyword := range in.PatrolKeywords { + if i >= patrolLimit { + break + } + if add(keyword) { + return out } } - for _, tpl := range cfg.PlanPeripheral { - add(renderQueryTemplate(tpl, vars)) - if len(out) >= cfg.MaxPlanQueries { - return capQueries(out, cfg.MaxPlanQueries) + + questionLimit := cfg.MaxQuestionQueries + if questionLimit <= 0 { + questionLimit = 3 + } + for i, question := range in.Questions { + if i >= questionLimit { + break + } + question = strings.TrimSpace(question) + if question == "" { + continue + } + if tpl := strings.TrimSpace(cfg.PlanQuestion); tpl != "" { + if add(renderQueryTemplate(tpl, map[string]string{"question": question})) { + return out + } + } else if add(question) { + return out } } + + pillarLimit := cfg.MaxPillarQueries + if pillarLimit <= 0 { + pillarLimit = 2 + } + for i, pillar := range in.Pillars { + if i >= pillarLimit { + break + } + pillar = strings.TrimSpace(pillar) + if pillar == "" { + continue + } + if tpl := strings.TrimSpace(cfg.PlanPillar); tpl != "" { + if add(renderQueryTemplate(tpl, map[string]string{"pillar": pillar})) { + return out + } + } else if add(pillar + " 請問") { + return out + } + } + + baseLimit := cfg.MaxPlanBaseQueries + if baseLimit <= 0 { + baseLimit = 3 + } + for i, tpl := range cfg.PlanBase { + if i >= baseLimit { + break + } + if add(renderQueryTemplate(tpl, vars)) { + return out + } + } + if vars["audience"] != "" && strings.TrimSpace(cfg.PlanAudience) != "" { - add(renderQueryTemplate(cfg.PlanAudience, vars)) + if add(renderQueryTemplate(cfg.PlanAudience, vars)) { + return out + } } - for _, label := range in.L1Labels { + + peripheralLimit := cfg.MaxPeripheralQueries + if in.Strategy == ExpandStrategyHybrid && peripheralLimit > 1 { + peripheralLimit = 1 + } + if peripheralLimit <= 0 { + peripheralLimit = 2 + } + for i, tpl := range cfg.PlanPeripheral { + if i >= peripheralLimit { + break + } + if add(renderQueryTemplate(tpl, vars)) { + return out + } + } + + l1Limit := cfg.MaxL1Labels + if l1Limit <= 0 { + l1Limit = 2 + } + for i, label := range in.L1Labels { + if i >= l1Limit { + break + } label = strings.TrimSpace(label) if label == "" || label == seed { continue } l1vars := map[string]string{"seed": seed, "label": label} - add(renderQueryTemplate(cfg.PlanL1Cause, l1vars)) - if len(out) >= cfg.MaxPlanQueries { - return capQueries(out, cfg.MaxPlanQueries) - } - add(renderQueryTemplate(cfg.PlanL1Pain, l1vars)) - if len(out) >= cfg.MaxPlanQueries { - return capQueries(out, cfg.MaxPlanQueries) + if add(renderQueryTemplate(cfg.PlanL1Pain, l1vars)) { + return out } } - return capQueries(out, cfg.MaxPlanQueries) + return out } -func supplementalQueries(cfg queryConfig, seed string, l1Labels []string) []string { - seed = strings.TrimSpace(seed) +func supplementalQueries(cfg queryConfig, in PlanInput) []string { + seed := strings.TrimSpace(in.Seed) if seed == "" { return nil } + budget := queryBudget(cfg, in.Strategy, true) + if budget <= 0 { + return nil + } seen := map[string]struct{}{} - out := make([]string, 0, cfg.MaxSupplemental) + out := make([]string, 0, budget) add := func(q string) { q = strings.TrimSpace(q) if q == "" { @@ -163,17 +301,68 @@ func supplementalQueries(cfg queryConfig, seed string, l1Labels []string) []stri for _, tpl := range cfg.Supplemental { add(renderQueryTemplate(tpl, vars)) } - for _, label := range l1Labels { + for _, pillar := range in.Pillars { + pillar = strings.TrimSpace(pillar) + if pillar == "" { + continue + } + if tpl := strings.TrimSpace(cfg.SupplementalPillar); tpl != "" { + add(renderQueryTemplate(tpl, map[string]string{"pillar": pillar})) + } + if len(out) >= budget { + return capQueries(out, budget) + } + } + for _, label := range in.L1Labels { label = strings.TrimSpace(label) if label == "" { continue } add(renderQueryTemplate(cfg.SupplementalL1, map[string]string{"seed": seed, "label": label})) - if len(out) >= cfg.MaxSupplemental { + if len(out) >= budget { break } } - return capQueries(out, cfg.MaxSupplemental) + return capQueries(out, budget) +} + +func PlanBreadthQueries(in PlanInput) []string { + in.Supplemental = true + return PlanQueries(in) +} + +// PlanBootstrapQueries builds Brave queries that do not depend on a generated research map. +func PlanBootstrapQueries(in PlanInput) []string { + bootstrap := in + bootstrap.Pillars = nil + bootstrap.Questions = nil + bootstrap.L1Labels = nil + bootstrap.Supplemental = false + return PlanQueries(bootstrap) +} + +// QueriesExcept returns planned queries that were not already executed. +func QueriesExcept(planned, executed []string) []string { + done := map[string]struct{}{} + for _, q := range executed { + q = strings.TrimSpace(q) + if q == "" { + continue + } + done[q] = struct{}{} + } + out := make([]string, 0, len(planned)) + for _, q := range planned { + q = strings.TrimSpace(q) + if q == "" { + continue + } + if _, ok := done[q]; ok { + continue + } + out = append(out, q) + } + return out } func BuildRecencyQuery(label string) string { diff --git a/haixun-backend/internal/library/knowledge/queries_test.go b/haixun-backend/internal/library/knowledge/queries_test.go index 10470b5..fcaa09a 100644 --- a/haixun-backend/internal/library/knowledge/queries_test.go +++ b/haixun-backend/internal/library/knowledge/queries_test.go @@ -1,6 +1,9 @@ package knowledge -import "testing" +import ( + "testing" + "unicode/utf8" +) func TestPlanQueriesCapsAtConfigLimit(t *testing.T) { queries := PlanQueries(PlanInput{ @@ -12,8 +15,8 @@ func TestPlanQueriesCapsAtConfigLimit(t *testing.T) { if len(queries) > max { t.Fatalf("expected <= %d queries, got %d", max, len(queries)) } - if len(queries) < 8 { - t.Fatalf("expected at least 8 queries, got %d", len(queries)) + if len(queries) < 4 { + t.Fatalf("expected at least 4 queries, got %d", len(queries)) } } @@ -24,10 +27,18 @@ func TestDeriveSearchTagsFromGraph(t *testing.T) { {ID: "n2", Label: "屏障受損", NodeKind: "symptom", Layer: 1}, }, } - DeriveSearchTagsFromGraph(&graph) + DeriveSearchTagsFromGraph(&graph, PatrolTagInput{ + Questions: []string{"敏感肌沐浴乳有推薦嗎"}, + Pillars: []string{"敏感肌沐浴用品挑選"}, + }) if graph.PainTagCount != 2 { t.Fatalf("expected pain tag count 2, got %d", graph.PainTagCount) } + for _, tag := range graph.Nodes[0].DerivedTags.Relevance { + if utf8.RuneCountInString(tag) < 6 { + t.Fatalf("expected human-length tag, got %q", tag) + } + } if len(graph.Nodes[0].DerivedTags.Relevance) == 0 { t.Fatal("expected relevance tags on core node") } diff --git a/haixun-backend/internal/library/knowledge/synth.go b/haixun-backend/internal/library/knowledge/synth.go index 82f883e..322c0a2 100644 --- a/haixun-backend/internal/library/knowledge/synth.go +++ b/haixun-backend/internal/library/knowledge/synth.go @@ -12,24 +12,40 @@ import ( ) type SynthInput struct { - Seed string - ProductBrief string - TargetAudience string - Persona string - Sources []BraveSource + BrandDisplayName string + TopicName string + ProductLabel string + Goals string + Seed string + ProductBrief string + TargetAudience string + Persona string + ResearchPillars []string + ResearchQuestions []string + Sources []BraveSource +} + +type rawSynthNode struct { + Label string `json:"label"` + NodeKind string `json:"nodeKind"` + NodeKindSnake string `json:"node_kind"` + Type string `json:"type"` + Layer int `json:"layer"` + Relation string `json:"relation"` + PlacementValue string `json:"placementValue"` + PlacementValueAlt string `json:"placement_value"` + ProductFitScore int `json:"productFitScore"` + ProductFitScoreAlt int `json:"product_fit_score"` + EvidenceURLs []string `json:"evidenceUrls"` + EvidenceURLsAlt []string `json:"evidence_urls"` + RelevanceQueries []string `json:"relevanceQueries"` + RelevanceQueriesAlt []string `json:"relevance_queries"` + RecencyQueries []string `json:"recencyQueries"` + RecencyQueriesAlt []string `json:"recency_queries"` } type rawSynthOutput struct { - Nodes []struct { - Label string `json:"label"` - NodeKind string `json:"nodeKind"` - Type string `json:"type"` - Layer int `json:"layer"` - Relation string `json:"relation"` - PlacementValue string `json:"placementValue"` - ProductFitScore int `json:"productFitScore"` - EvidenceURLs []string `json:"evidenceUrls"` - } `json:"nodes"` + Nodes []rawSynthNode `json:"nodes"` Edges []struct { From string `json:"from"` To string `json:"to"` @@ -37,7 +53,7 @@ type rawSynthOutput struct { } `json:"edges"` } -var codeFenceRE = regexp.MustCompile("(?s)^```(?:json)?\\s*(.*?)\\s*```$") +var codeFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```") func BuildUserPrompt(in SynthInput) (string, error) { var sources strings.Builder @@ -51,16 +67,45 @@ func BuildUserPrompt(in SynthInput) (string, error) { i+1, src.Query, src.URL, src.Title, src.Snippet) } vars := map[string]string{ - "seed": strings.TrimSpace(in.Seed), - "product_brief_line": optionalLine("產品簡述", in.ProductBrief), - "target_audience_line": optionalLine("目標受眾", in.TargetAudience), - "persona_line": optionalLine("人設", in.Persona), - "sources": strings.TrimSpace(sources.String()), + "brand_line": optionalLine("品牌", in.BrandDisplayName), + "topic_line": optionalLine("主題名稱", in.TopicName), + "product_line": optionalLine("置入產品", in.ProductLabel), + "goals_line": optionalLine("置入目標", in.Goals), + "seed": strings.TrimSpace(in.Seed), + "product_brief_line": optionalLine("產品簡述", in.ProductBrief), + "target_audience_line": optionalLine("目標受眾", in.TargetAudience), + "persona_line": optionalLine("主題目標", in.Persona), + "research_pillars_line": bulletLine("內容支柱(延伸知識要往這些方向廣泛展開)", in.ResearchPillars), + "research_questions_line": bulletLine("受眾提問方向(可衍生成更多周邊節點)", in.ResearchQuestions), + "sources": strings.TrimSpace(sources.String()), } return libprompt.KnowledgeGraphUser(vars) } func optionalLine(label, value string) string { + return OptionalPromptLine(label, value) +} + +func BulletPromptLine(title string, items []string) string { + return bulletLine(title, items) +} + +func bulletLine(title string, items []string) string { + lines := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + lines = append(lines, "- "+item) + } + if len(lines) == 0 { + return "" + } + return title + ":\n" + strings.Join(lines, "\n") + "\n" +} + +func OptionalPromptLine(label, value string) string { value = strings.TrimSpace(value) if value == "" { return "" @@ -101,7 +146,7 @@ func ParseSynthOutput(raw string, in SynthInput, sources []BraveSource) (Graph, } layer := item.Layer nodeType := strings.TrimSpace(item.Type) - nodeKind := strings.TrimSpace(item.NodeKind) + nodeKind := firstNonEmpty(item.NodeKind, item.NodeKindSnake) if layer == 0 || nodeType == "core" { layer = 0 nodeType = "core" @@ -119,8 +164,12 @@ func ParseSynthOutput(raw string, in SynthInput, sources []BraveSource) (Graph, nodeKind = "knowledge" } } - evidence := make([]Evidence, 0, len(item.EvidenceURLs)) - for _, u := range item.EvidenceURLs { + evidenceURLs := item.EvidenceURLs + if len(evidenceURLs) == 0 { + evidenceURLs = item.EvidenceURLsAlt + } + evidence := make([]Evidence, 0, len(evidenceURLs)) + for _, u := range evidenceURLs { u = strings.TrimSpace(u) if u == "" { continue @@ -133,9 +182,13 @@ func ParseSynthOutput(raw string, in SynthInput, sources []BraveSource) (Graph, evidence = append(evidence, ev) } fit := item.ProductFitScore + if fit <= 0 { + fit = item.ProductFitScoreAlt + } if fit <= 0 { fit = defaultProductFit(nodeKind, layer) } + placementValue := firstNonEmpty(item.PlacementValue, item.PlacementValueAlt) graph.Nodes = append(graph.Nodes, Node{ ID: uuid.NewString(), Label: label, @@ -143,8 +196,10 @@ func ParseSynthOutput(raw string, in SynthInput, sources []BraveSource) (Graph, Type: nodeType, Layer: layer, Relation: strings.TrimSpace(item.Relation), - PlacementValue: normalizePlacement(item.PlacementValue, nodeKind), + PlacementValue: normalizePlacementReason(placementValue, item.Relation, nodeKind, fit), ProductFitScore: fit, + PatrolRelevance: mergeStringLists(item.RelevanceQueries, item.RelevanceQueriesAlt), + PatrolRecency: mergeStringLists(item.RecencyQueries, item.RecencyQueriesAlt), Evidence: evidence, }) } @@ -156,7 +211,8 @@ func ParseSynthOutput(raw string, in SynthInput, sources []BraveSource) (Graph, NodeKind: "pain", Type: "core", Layer: 0, - PlacementValue: "high", + Relation: "核心種子主題", + PlacementValue: "核心痛點帖最常求推薦,適合以產品使用經驗自然回覆", ProductFitScore: 90, }}, graph.Nodes...) } @@ -178,10 +234,37 @@ func ParseSynthOutput(raw string, in SynthInput, sources []BraveSource) (Graph, }) } - DeriveSearchTagsFromGraph(&graph) return graph, nil } +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mergeStringLists(groups ...[]string) []string { + out := []string{} + seen := map[string]struct{}{} + for _, group := range groups { + for _, item := range group { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + } + return out +} + func defaultProductFit(nodeKind string, layer int) int { switch nodeKind { case "pain": @@ -196,16 +279,31 @@ func defaultProductFit(nodeKind string, layer int) int { } } -func normalizePlacement(value, nodeKind string) string { - value = strings.TrimSpace(strings.ToLower(value)) - switch value { - case "high", "medium", "low": - return value +func normalizePlacementReason(value, relation, nodeKind string, productFit int) string { + value = strings.TrimSpace(value) + if value != "" { + switch strings.ToLower(value) { + case "high": + return "受眾在此情境常有明確產品需求,適合自然分享使用經驗" + case "medium": + return "與產品使用情境相關,可輕量帶入經驗而不硬推" + case "low": + return "多為背景脈絡,置入需非常克制" + default: + return value + } + } + relation = strings.TrimSpace(relation) + if relation != "" && productFit >= 70 && IsPainNode(Node{NodeKind: nodeKind}) { + return "與「" + relation + "」相關的求助帖,有機會自然帶入產品經驗" } if IsPainNode(Node{NodeKind: nodeKind}) { - return "high" + return "痛點類討論串,可視情境分享產品使用心得" } - return "low" + if productFit >= 60 { + return "與產品使用情境相關,可作輕量經驗分享" + } + return "" } func resolveNodeRef(ref string, labelToID map[string]string, nodes []Node) string { @@ -233,9 +331,46 @@ func extractJSONObject(raw string) ([]byte, error) { text = strings.TrimSpace(m[1]) } start := strings.Index(text, "{") - end := strings.LastIndex(text, "}") - if start < 0 || end <= start { + if start < 0 { return nil, fmt.Errorf("LLM response does not contain JSON object") } + end, ok := matchJSONObjectEnd(text, start) + if !ok { + return nil, fmt.Errorf("LLM response does not contain complete JSON object") + } return []byte(text[start : end+1]), nil } + +func matchJSONObjectEnd(text string, start int) (int, bool) { + depth := 0 + inString := false + escaped := false + for i := start; i < len(text); i++ { + ch := text[i] + if inString { + if escaped { + escaped = false + continue + } + switch ch { + case '\\': + escaped = true + case '"': + inString = false + } + continue + } + switch ch { + case '"': + inString = true + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return i, true + } + } + } + return 0, false +} diff --git a/haixun-backend/internal/library/knowledge/synth_test.go b/haixun-backend/internal/library/knowledge/synth_test.go new file mode 100644 index 0000000..a2ea17c --- /dev/null +++ b/haixun-backend/internal/library/knowledge/synth_test.go @@ -0,0 +1,83 @@ +package knowledge + +import "testing" + +func TestNormalizePlacementReason(t *testing.T) { + got := normalizePlacementReason("high", "", "pain", 90) + if got == "" || got == "high" { + t.Fatalf("expected prose for legacy high, got %q", got) + } + + got = normalizePlacementReason("換季泛紅帖常求溫和修護產品", "", "pain", 85) + if got != "換季泛紅帖常求溫和修護產品" { + t.Fatalf("expected prose preserved, got %q", got) + } +} + +func TestExtractJSONObject(t *testing.T) { + valid := `{"nodes":[{"label":"a","evidenceUrls":[]}],"edges":[]}` + cases := []struct { + name string + raw string + wantErr bool + }{ + {name: "plain", raw: valid}, + {name: "prefixed text", raw: "分析完成。\n" + valid}, + {name: "code fence", raw: "說明如下:\n```json\n" + valid + "\n```"}, + {name: "trailing object", raw: valid + `,{"summary":"ignored"}`}, + {name: "brace in string", raw: `{"nodes":[{"label":"含{符號}的節點","evidenceUrls":[]}],"edges":[]}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + payload, err := extractJSONObject(tc.raw) + if tc.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatal(err) + } + graph, err := ParseSynthOutput(string(payload), SynthInput{Seed: "種子"}, nil) + if err != nil { + t.Fatal(err) + } + if len(graph.Nodes) == 0 { + t.Fatal("expected nodes") + } + }) + } +} + +func TestParseSynthOutputPlacementReason(t *testing.T) { + raw := `{ + "nodes": [ + { + "label": "換季泛紅", + "nodeKind": "symptom", + "type": "symptom", + "layer": 1, + "relation": "溫差變化會讓敏感肌臉頰泛紅刺痛", + "placementValue": "求助帖常問舒緩產品,可分享溫和修護的實際經驗", + "productFitScore": 82, + "evidenceUrls": [] + } + ], + "edges": [] +}` + graph, err := ParseSynthOutput(raw, SynthInput{Seed: "敏感肌"}, nil) + if err != nil { + t.Fatal(err) + } + if len(graph.Nodes) < 2 { + t.Fatalf("expected core + parsed node, got %d nodes", len(graph.Nodes)) + } + node := graph.Nodes[len(graph.Nodes)-1] + if node.Relation == "" { + t.Fatal("expected relation") + } + if node.PlacementValue == "" || node.PlacementValue == "high" { + t.Fatalf("expected placement prose, got %q", node.PlacementValue) + } +} diff --git a/haixun-backend/internal/library/placement/ai_generate.go b/haixun-backend/internal/library/placement/ai_generate.go new file mode 100644 index 0000000..374eee8 --- /dev/null +++ b/haixun-backend/internal/library/placement/ai_generate.go @@ -0,0 +1,13 @@ +package placement + +import domai "haixun-backend/internal/model/ai/domain/usecase" + +// ResearchGenerateRequest wraps a text generation call with defaults tuned for +// placement research map / knowledge graph (full JSON, not terse summaries). +func ResearchGenerateRequest(base domai.GenerateRequest) domai.GenerateRequest { + temp := 0.45 + tokens := 12288 + base.Temperature = &temp + base.MaxTokens = &tokens + return base +} diff --git a/haixun-backend/internal/library/placement/context.go b/haixun-backend/internal/library/placement/context.go index 45f6454..7027b81 100644 --- a/haixun-backend/internal/library/placement/context.go +++ b/haixun-backend/internal/library/placement/context.go @@ -32,6 +32,7 @@ type ResearchSettings struct { BraveAPIKey string BraveCountry string BraveSearchLang string + ExpandStrategy string } func BuildMemberContext( diff --git a/haixun-backend/internal/library/placement/context_prompt.go b/haixun-backend/internal/library/placement/context_prompt.go new file mode 100644 index 0000000..ddb1a9b --- /dev/null +++ b/haixun-backend/internal/library/placement/context_prompt.go @@ -0,0 +1,151 @@ +package placement + +import ( + "strings" + + branddomain "haixun-backend/internal/model/brand/domain/usecase" + topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase" +) + +// PlacementTopicContext bundles brand, product, and user-entered topic fields for prompts. +type PlacementTopicContext struct { + BrandDisplayName string + TopicName string + SeedQuery string + Brief string + Goals string + TargetAudience string + ProductContext string + ProductBrief string + ProductLabel string +} + +func PlacementTopicContextFromTopic(topic *topicdomain.TopicSummary, brand *branddomain.BrandSummary, productBrief string) PlacementTopicContext { + if topic == nil { + return PlacementTopicContextFromBrand(brand, productBrief) + } + merged := brandSummaryForTopic(topic, brand) + return PlacementTopicContextFromBrand(merged, productBrief) +} + +func brandSummaryForTopic(topic *topicdomain.TopicSummary, brand *branddomain.BrandSummary) *branddomain.BrandSummary { + if brand == nil { + return nil + } + out := *brand + out.TopicName = strings.TrimSpace(topic.TopicName) + out.SeedQuery = strings.TrimSpace(topic.SeedQuery) + out.Brief = strings.TrimSpace(topic.Brief) + out.ProductID = strings.TrimSpace(topic.ProductID) + out.ResearchMap = topic.ResearchMap + return &out +} + +func PlacementTopicContextFromBrand(brand *branddomain.BrandSummary, productBrief string) PlacementTopicContext { + if brand == nil { + return PlacementTopicContext{} + } + ctx := PlacementTopicContext{ + BrandDisplayName: strings.TrimSpace(brand.DisplayName), + TopicName: strings.TrimSpace(brand.TopicName), + SeedQuery: strings.TrimSpace(brand.SeedQuery), + Brief: strings.TrimSpace(brand.Brief), + Goals: strings.TrimSpace(brand.Goals), + TargetAudience: strings.TrimSpace(brand.TargetAudience), + ProductContext: strings.TrimSpace(brand.ProductContext), + ProductBrief: strings.TrimSpace(productBrief), + ProductLabel: productLabelFromBrand(brand), + } + if ctx.ProductBrief == "" { + ctx.ProductBrief = strings.TrimSpace(brand.ProductBrief) + } + return ctx +} + +func productLabelFromBrand(brand *branddomain.BrandSummary) string { + if brand == nil { + return "" + } + productID := strings.TrimSpace(brand.ProductID) + if productID == "" { + return "" + } + for _, item := range brand.Products { + if item.ID == productID { + return strings.TrimSpace(item.Label) + } + } + return "" +} + +func (c PlacementTopicContext) ToResearchMapInput() ResearchMapInput { + return ResearchMapInput{ + BrandDisplayName: c.BrandDisplayName, + TopicName: c.TopicName, + SeedQuery: c.SeedQuery, + Brief: c.Brief, + Goals: c.Goals, + TargetAudience: c.TargetAudience, + ProductContext: c.ProductContext, + ProductBrief: c.ProductBrief, + ProductLabel: c.ProductLabel, + } +} + +func (c PlacementTopicContext) WritePromptBlock(b *strings.Builder) { + writePromptSection(b, "品牌", c.BrandDisplayName) + writePromptSection(b, "置入產品", c.ProductDisplayName()) + writePromptSection(b, "主題名稱", c.TopicName) + writePromptSection(b, "種子關鍵字", c.SeedQuery) + writePromptSection(b, "主題目標", c.Brief) + writePromptSection(b, "置入目標/備註", c.Goals) + writePromptSection(b, "已知受眾描述", c.TargetAudience) + productDetail := FormatProductContextForPrompt(c.ProductContext) + if productDetail == "" && c.ProductBrief != "" { + productDetail = c.ProductBrief + } + if productDetail != "" { + b.WriteString("【產品詳情】\n") + b.WriteString(productDetail) + b.WriteString("\n") + } +} + +func (c PlacementTopicContext) ProductDisplayName() string { + fields := ParseProductContext(c.ProductContext) + if fields.Product != "" { + return fields.Product + } + return c.ProductLabel +} + +func writePromptSection(b *strings.Builder, label, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + b.WriteString("【") + b.WriteString(label) + b.WriteString("】") + b.WriteString(value) + b.WriteString("\n") +} + +// PromptLines returns optional labeled lines for knowledge graph template vars. +func (c PlacementTopicContext) PromptLines() map[string]string { + productName := c.ProductDisplayName() + return map[string]string{ + "brand_line": optionalPromptLine("品牌", c.BrandDisplayName), + "topic_line": optionalPromptLine("主題名稱", c.TopicName), + "product_line": optionalPromptLine("置入產品", productName), + "goals_line": optionalPromptLine("置入目標", c.Goals), + } +} + +func optionalPromptLine(label, value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return label + ":" + value + "\n" +} diff --git a/haixun-backend/internal/library/placement/context_prompt_test.go b/haixun-backend/internal/library/placement/context_prompt_test.go new file mode 100644 index 0000000..9f7653c --- /dev/null +++ b/haixun-backend/internal/library/placement/context_prompt_test.go @@ -0,0 +1,58 @@ +package placement + +import ( + "strings" + "testing" + + branddomain "haixun-backend/internal/model/brand/domain/usecase" +) + +func TestPlacementTopicContextWritePromptBlock(t *testing.T) { + ctx := PlacementTopicContextFromBrand(&branddomain.BrandSummary{ + DisplayName: "ecostore", + TopicName: "癌症病友沐浴敏感", + SeedQuery: "化療 無香 沐浴乳", + Brief: "找近期求助換沐浴乳的貼文", + ProductID: "p1", + Products: []branddomain.ProductSummary{ + {ID: "p1", Label: "抗敏無香沐浴露", ProductContext: `{"brand":"ecostore","product":"抗敏無香沐浴露","features":"完全無香、抗敏、第三方認證"}`}, + }, + ProductContext: `{"brand":"ecostore","product":"抗敏無香沐浴露","features":"完全無香、抗敏、第三方認證"}`, + }, "") + + var b strings.Builder + ctx.WritePromptBlock(&b) + out := b.String() + for _, want := range []string{ + "【品牌】ecostore", + "【置入產品】抗敏無香沐浴露", + "【主題名稱】癌症病友沐浴敏感", + "【種子關鍵字】化療 無香 沐浴乳", + "【主題目標】找近期求助換沐浴乳的貼文", + "【產品詳情】", + "完全無香、抗敏、第三方認證", + } { + if !strings.Contains(out, want) { + t.Fatalf("prompt block missing %q:\n%s", want, out) + } + } +} + +func TestBuildResearchMapAnalysisPrompts(t *testing.T) { + in := PlacementTopicContext{ + BrandDisplayName: "ecostore", + TopicName: "癌症病友沐浴敏感", + }.ToResearchMapInput() + sys := BuildResearchMapAnalysisSystemPrompt() + if !strings.Contains(sys, "不要輸出 JSON") { + t.Fatal("analysis system prompt should forbid JSON") + } + user := BuildResearchMapAnalysisUserPrompt(in) + if !strings.Contains(user, "【品牌】ecostore") { + t.Fatalf("analysis user prompt missing brand: %s", user) + } + final := BuildResearchMapFinalizeUserPrompt(in, "分析段落") + if !strings.Contains(final, "【前置分析】") || !strings.Contains(final, "分析段落") { + t.Fatalf("finalize prompt missing analysis: %s", final) + } +} diff --git a/haixun-backend/internal/library/placement/crawler_polite.go b/haixun-backend/internal/library/placement/crawler_polite.go new file mode 100644 index 0000000..5331854 --- /dev/null +++ b/haixun-backend/internal/library/placement/crawler_polite.go @@ -0,0 +1,58 @@ +package placement + +import ( + "context" + "math/rand" + "sync" + "time" +) + +const ( + CrawlerMinQueryInterval = 8 * time.Second + CrawlerMaxQueryJitter = 4 * time.Second +) + +// WrapPoliteCrawler spaces out Playwright keyword searches to reduce Threads rate limits. +func WrapPoliteCrawler(inner CrawlerSearchFn) CrawlerSearchFn { + if inner == nil { + return nil + } + guard := &crawlerPacing{inner: inner} + return guard.search +} + +type crawlerPacing struct { + inner CrawlerSearchFn + mu sync.Mutex + last time.Time +} + +func (p *crawlerPacing) search(ctx context.Context, member MemberContext, keyword string, limit int) ([]DiscoverPost, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.last.IsZero() { + elapsed := time.Since(p.last) + wait := CrawlerMinQueryInterval + jitterDuration(CrawlerMaxQueryJitter) - elapsed + if wait > 0 { + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + } + } + } + + posts, err := p.inner(ctx, member, keyword, limit) + p.last = time.Now() + return posts, err +} + +func jitterDuration(max time.Duration) time.Duration { + if max <= 0 { + return 0 + } + return time.Duration(rand.Int63n(int64(max))) +} diff --git a/haixun-backend/internal/library/placement/dual_track.go b/haixun-backend/internal/library/placement/dual_track.go index 4f43a2f..8fcb714 100644 --- a/haixun-backend/internal/library/placement/dual_track.go +++ b/haixun-backend/internal/library/placement/dual_track.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" libbrave "haixun-backend/internal/library/brave" libkg "haixun-backend/internal/library/knowledge" @@ -32,16 +33,19 @@ type ScanCandidate struct { EngagementScore int PlacementScore int SolvedByProduct bool + PostedAt string Replies []ReplyCandidate } type DualTrackInput struct { - Nodes []libkg.Node - Exclusions []string - Member MemberContext - Client *libbrave.Client - Crawler CrawlerSearchFn - Limit int // max queries budget; 0 = default + Nodes []libkg.Node + PatrolKeywords []string + Exclusions []string + Member MemberContext + Client *libbrave.Client + Crawler CrawlerSearchFn + Limit int // max queries budget; 0 = default + OnCheckpoint func(candidates []ScanCandidate) error } type DualTrackProgress func(message string, pct int) @@ -105,8 +109,11 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery { // RunDualTrackDiscover executes relevance + recency queries and merges by permalink. func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) { - queries := CollectTagQueries(input.Nodes) + queries := ResolveTagQueries(input.Nodes, input.PatrolKeywords) if len(queries) == 0 { + if len(input.PatrolKeywords) > 0 { + return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句") + } return nil, fmt.Errorf("沒有勾選的節點或可用 tag") } @@ -156,6 +163,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress Priority: priority, PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency), SolvedByProduct: tq.ProductFitScore >= 55, + PostedAt: strings.TrimSpace(post.PostedAt), } order = append(order, key) continue @@ -170,6 +178,9 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress existing.ProductFitScore = tq.ProductFitScore existing.SolvedByProduct = tq.ProductFitScore >= 55 } + if strings.TrimSpace(existing.PostedAt) == "" && strings.TrimSpace(post.PostedAt) != "" { + existing.PostedAt = strings.TrimSpace(post.PostedAt) + } } return nil } @@ -187,25 +198,20 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress if err := runQuery(tq, limit); err != nil { return nil, err } + if input.OnCheckpoint != nil { + snapshot := snapshotMergedCandidates(merged, order, false) + if err := input.OnCheckpoint(snapshot); err != nil { + return nil, err + } + } + if input.Member.AllowsCrawler && input.Member.DevMode && i < total-1 { + if err := politeDiscoverPause(ctx); err != nil { + return nil, err + } + } } - out := make([]ScanCandidate, 0, len(order)) - for _, key := range order { - item := merged[key] - if item.HasRelevance && item.HasRecency && item.ProductFitScore >= 45 { - item.Priority = "gold" - } else if item.HasRecency { - item.Priority = "recent" - } else { - item.Priority = "relevant" - } - if item.ProductFitScore < 30 && item.Priority != "gold" { - continue - } - item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency) - item.SolvedByProduct = item.ProductFitScore >= 55 - out = append(out, *item) - } + out := snapshotMergedCandidates(merged, order, true) if onProgress != nil { onProgress(fmt.Sprintf("合併完成,共 %d 篇候選貼文", len(out)), 90) } @@ -272,6 +278,34 @@ func discoverViaBrave(ctx context.Context, client *libbrave.Client, member Membe return out, nil } +func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool) []ScanCandidate { + out := make([]ScanCandidate, 0, len(order)) + for _, key := range order { + item := merged[key] + finalizeScanCandidate(item) + if applyFinalFilter && item.ProductFitScore < 30 && item.Priority != "gold" { + continue + } + out = append(out, *item) + } + return out +} + +func finalizeScanCandidate(item *ScanCandidate) { + if item == nil { + return + } + if item.HasRelevance && item.HasRecency && item.ProductFitScore >= 45 { + item.Priority = "gold" + } else if item.HasRecency { + item.Priority = "recent" + } else { + item.Priority = "relevant" + } + item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency) + item.SolvedByProduct = item.ProductFitScore >= 55 +} + func computePlacementScore(text string, productFit int, recent bool) int { score := 30 + productFit/4 if HasPlacementIntent(text) { @@ -298,3 +332,15 @@ func max(a, b int) int { } return b } + +func politeDiscoverPause(ctx context.Context) error { + wait := 2*time.Second + jitterDuration(2*time.Second) + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/haixun-backend/internal/library/placement/patrol_queries.go b/haixun-backend/internal/library/placement/patrol_queries.go new file mode 100644 index 0000000..dc73675 --- /dev/null +++ b/haixun-backend/internal/library/placement/patrol_queries.go @@ -0,0 +1,101 @@ +package placement + +import ( + "strings" + + libkg "haixun-backend/internal/library/knowledge" +) + +const defaultPatrolProductFit = 78 + +// CollectPatrolTagQueries builds dual-track crawl jobs from user-edited patrol keywords only. +func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node) []TagQuery { + keywords = libkg.NormalizePatrolKeywordList(keywords) + if len(keywords) == 0 { + return nil + } + + out := make([]TagQuery, 0, len(keywords)*3) + for _, tag := range keywords { + fit := productFitForPatrolTag(tag, nodes) + if q := BuildRelevanceQuery(tag); q != "" { + out = append(out, TagQuery{ + Tag: tag, + Query: q, + Dimension: QueryRelevance, + ProductFitScore: fit, + }) + } + if q7 := BuildRecencyQuery(tag, IdealMaxPostAgeDays); q7 != "" { + out = append(out, TagQuery{ + Tag: tag, + Query: q7, + Dimension: QueryRecency, + ProductFitScore: fit, + RecencyDays: IdealMaxPostAgeDays, + }) + } + if q30 := BuildRecencyQuery(tag, MaxPostAgeDays); q30 != "" { + if q7 := BuildRecencyQuery(tag, IdealMaxPostAgeDays); q30 != q7 { + out = append(out, TagQuery{ + Tag: tag, + Query: q30, + Dimension: QueryRecency, + ProductFitScore: fit, + RecencyDays: MaxPostAgeDays, + }) + } + } + } + return out +} + +func productFitForPatrolTag(tag string, nodes []libkg.Node) int { + tagKey := patrolTagMatchKey(tag) + best := 0 + bestNodeID := "" + for _, node := range nodes { + score := node.ProductFitScore + if score <= 0 { + continue + } + matched := false + for _, candidate := range append(append([]string{}, node.DerivedTags.Relevance...), node.DerivedTags.Recency...) { + if patrolTagMatchKey(candidate) == tagKey { + matched = true + break + } + } + if !matched && patrolTagMatchKey(node.Label) != tagKey { + continue + } + if score > best { + best = score + bestNodeID = node.ID + } + } + if best > 0 { + return best + } + _ = bestNodeID + return defaultPatrolProductFit +} + +func patrolTagMatchKey(tag string) string { + tag = strings.TrimSpace(tag) + for _, suffix := range []string{" 推薦", " 請問", " 怎麼辦", " 好用嗎", " 有人用過嗎", " 有推薦嗎", " 請益"} { + if strings.HasSuffix(tag, suffix) { + tag = strings.TrimSuffix(tag, suffix) + break + } + } + return strings.TrimSpace(tag) +} + +// ResolveTagQueries prefers explicit patrol keywords over graph node selection. +func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string) []TagQuery { + if len(patrolKeywords) > 0 { + return CollectPatrolTagQueries(patrolKeywords, nodes) + } + return CollectTagQueries(nodes) +} diff --git a/haixun-backend/internal/library/placement/patrol_queries_test.go b/haixun-backend/internal/library/placement/patrol_queries_test.go new file mode 100644 index 0000000..7c773cf --- /dev/null +++ b/haixun-backend/internal/library/placement/patrol_queries_test.go @@ -0,0 +1,47 @@ +package placement + +import ( + "testing" + + libkg "haixun-backend/internal/library/knowledge" +) + +func TestCollectPatrolTagQueriesManualOnly(t *testing.T) { + queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil) + if len(queries) < 2 { + t.Fatalf("expected relevance + recency queries, got %d", len(queries)) + } + if queries[0].Tag != "化療 沐浴乳" || queries[0].Dimension != QueryRelevance { + t.Fatalf("unexpected first query: %+v", queries[0]) + } + if queries[0].ProductFitScore != defaultPatrolProductFit { + t.Fatalf("expected default fit %d, got %d", defaultPatrolProductFit, queries[0].ProductFitScore) + } +} + +func TestCollectPatrolTagQueriesUsesGraphFit(t *testing.T) { + nodes := []libkg.Node{ + { + ID: "n1", + Label: "化療 沐浴乳", + ProductFitScore: 92, + DerivedTags: libkg.DerivedTags{ + Relevance: []string{"化療 沐浴乳"}, + }, + }, + } + queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes) + if len(queries) == 0 || queries[0].ProductFitScore != 92 { + t.Fatalf("expected graph fit 92, got %+v", queries) + } +} + +func TestResolveTagQueriesPrefersPatrolKeywords(t *testing.T) { + nodes := []libkg.Node{ + {ID: "n1", Label: "ignored", SelectedForScan: true, DerivedTags: libkg.DerivedTags{Relevance: []string{"ignored"}}}, + } + queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"}) + if len(queries) == 0 || queries[0].Tag != "手動 關鍵字" { + t.Fatalf("expected patrol keyword query, got %+v", queries) + } +} diff --git a/haixun-backend/internal/library/placement/product_catalog.go b/haixun-backend/internal/library/placement/product_catalog.go new file mode 100644 index 0000000..29ed36f --- /dev/null +++ b/haixun-backend/internal/library/placement/product_catalog.go @@ -0,0 +1,86 @@ +package placement + +import ( + "strings" + + brandentity "haixun-backend/internal/model/brand/domain/entity" +) + +func BuildMergedProductContext(brandName, productContext, productLabel string) string { + fields := ParseProductContext(productContext) + if name := strings.TrimSpace(brandName); name != "" { + fields.Brand = name + } + if label := strings.TrimSpace(productLabel); label != "" && fields.Product == "" { + fields.Product = label + } + return SerializeProductContext(fields) +} + +func RecommendProduct(products []brandentity.Product, searchTag, defaultProductID string) *brandentity.Product { + if len(products) == 0 { + return nil + } + var defaultProduct *brandentity.Product + if id := strings.TrimSpace(defaultProductID); id != "" { + for i := range products { + if products[i].ID == id { + defaultProduct = &products[i] + break + } + } + } + tag := strings.TrimSpace(searchTag) + if tag == "" { + if defaultProduct != nil { + return defaultProduct + } + return &products[0] + } + + bestScore := -1 + var best *brandentity.Product + for i := range products { + score := ScoreProductForTag(tag, products[i].MatchTags) + if score > bestScore { + bestScore = score + best = &products[i] + } + } + if best != nil && bestScore > 0 { + return best + } + if defaultProduct != nil { + return defaultProduct + } + return &products[0] +} + +func ResolveBrandProductContext(brand brandentity.Brand, productID, searchTag string) string { + if len(brand.Products) > 0 { + picked := RecommendProduct(brand.Products, searchTag, productID) + if picked != nil { + return BuildMergedProductContext(brand.DisplayName, picked.ProductContext, picked.Label) + } + } + if formatted := ProductBriefFromContext(brand.ProductContext); formatted != "" { + return formatted + } + if brief := strings.TrimSpace(brand.ProductBrief); brief != "" { + return brief + } + return "" +} + +func FindProduct(brand brandentity.Brand, productID string) *brandentity.Product { + id := strings.TrimSpace(productID) + if id == "" { + return nil + } + for i := range brand.Products { + if brand.Products[i].ID == id { + return &brand.Products[i] + } + } + return nil +} diff --git a/haixun-backend/internal/library/placement/product_match.go b/haixun-backend/internal/library/placement/product_match.go new file mode 100644 index 0000000..81d4203 --- /dev/null +++ b/haixun-backend/internal/library/placement/product_match.go @@ -0,0 +1,48 @@ +package placement + +import ( + "strings" +) + +func ScoreProductForTag(searchTag string, matchTags []string) int { + tag := strings.TrimSpace(strings.ToLower(searchTag)) + if tag == "" || len(matchTags) == 0 { + return 0 + } + best := 0 + for _, raw := range matchTags { + candidate := strings.TrimSpace(strings.ToLower(raw)) + if candidate == "" { + continue + } + switch { + case tag == candidate: + best = maxInt(best, 100) + case strings.Contains(tag, candidate) || strings.Contains(candidate, tag): + best = maxInt(best, 70) + default: + tagWords := strings.Fields(tag) + candWords := strings.Fields(candidate) + overlap := 0 + for _, w := range tagWords { + for _, c := range candWords { + if strings.Contains(c, w) || strings.Contains(w, c) { + overlap++ + break + } + } + } + if overlap > 0 { + best = maxInt(best, 40+overlap*10) + } + } + } + return best +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/haixun-backend/internal/library/placement/research_map.go b/haixun-backend/internal/library/placement/research_map.go index 15de822..7754ce6 100644 --- a/haixun-backend/internal/library/placement/research_map.go +++ b/haixun-backend/internal/library/placement/research_map.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" "strings" + "unicode/utf8" ) type ResearchMap struct { @@ -13,46 +14,206 @@ type ResearchMap struct { Questions []string `json:"questions"` Pillars []string `json:"pillars"` Exclusions []string `json:"exclusions"` + PatrolKeywords []string `json:"patrolKeywords"` } type ResearchMapInput struct { - Label string - SeedQuery string - Brief string - ProductContext string + BrandDisplayName string + TopicName string + SeedQuery string + Brief string + Goals string + TargetAudience string + ProductContext string + ProductBrief string + ProductLabel string } +const ( + minResearchQuestions = 8 + minResearchPillars = 6 + minResearchExclusions = 8 + minAudienceRunes = 80 + minContentGoalRunes = 50 +) + var researchMapFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```") -func BuildResearchMapSystemPrompt() string { - return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。目標是幫品牌找到「近期發文、作者有需求、現在留言還來得及自然推薦產品」的貼文。 +func BuildResearchMapAnalysisSystemPrompt() string { + return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。請先閱讀使用者提供的**品牌、置入產品、主題目標**等輸入,完成結構化分析。 -規則: -1. questions 與 pillars 會直接拿去 Threads 搜尋——每句 5~20 字,像真人求助 -2. questions 至少 5 個;pillars 至少 4 個;exclusions 至少 4 個 -3. contentGoal 要寫:找到近期發文且可自然留言置入的貼文 -4. 全部繁體中文,貼近台灣 Threads -5. 只回傳一個 JSON:audienceSummary, contentGoal, questions, pillars, exclusions`) +## 任務 +- 整合所有輸入,想清楚受眾是誰、他們在煩什麼、產品能解決什麼、什麼貼文值得留言置入 +- **本步驟只輸出分析文字,不要輸出 JSON** + +## 請依序回答(每段 2~4 句,繁體中文,具體可執行) +1. **品牌與產品**:這個品牌/產品能解決什麼具體困擾?關鍵賣點是什麼? +2. **受眾輪廓**:誰、在什麼生活/治療情境、會因什麼症狀發文求助? +3. **求助句型方向**:Threads 上可能出現哪些求助句(列 6~10 個方向,尚未定稿) +4. **內容方向與排除**:適合找文的支柱主題、必須排除的貼文類型(排除項只寫內容類型,不寫發文時間) +5. **置入策略**:contentGoal 應點名的完整產品名稱、近期發文窗口、留言置入時機 + +分析必須緊扣輸入資料,不可套用與品牌/產品/主題無關的通用範例。`) } -func BuildResearchMapUserPrompt(in ResearchMapInput) string { +func BuildResearchMapAnalysisUserPrompt(in ResearchMapInput) string { var b strings.Builder - b.WriteString("【主題名稱】") - b.WriteString(strings.TrimSpace(in.Label)) - b.WriteString("\n【種子關鍵字】") - b.WriteString(strings.TrimSpace(in.SeedQuery)) - b.WriteString("\n【這個主題想做什么】\n") - b.WriteString(strings.TrimSpace(in.Brief)) - b.WriteString("\n【產品置入】\n") - product := FormatProductContextForPrompt(in.ProductContext) - if product == "" { - product = "(尚未填寫)" - } - b.WriteString(product) - b.WriteString("\n\n請產出研究地圖 JSON。") + b.WriteString("以下為本次置入主題的完整輸入,請先分析:\n\n") + PlacementTopicContext{ + BrandDisplayName: in.BrandDisplayName, + TopicName: in.TopicName, + SeedQuery: in.SeedQuery, + Brief: in.Brief, + Goals: in.Goals, + TargetAudience: in.TargetAudience, + ProductContext: in.ProductContext, + ProductBrief: in.ProductBrief, + ProductLabel: in.ProductLabel, + }.WritePromptBlock(&b) return b.String() } +func BuildResearchMapFinalizeUserPrompt(in ResearchMapInput, analysis string) string { + var b strings.Builder + b.WriteString("【輸入資料】\n") + PlacementTopicContext{ + BrandDisplayName: in.BrandDisplayName, + TopicName: in.TopicName, + SeedQuery: in.SeedQuery, + Brief: in.Brief, + Goals: in.Goals, + TargetAudience: in.TargetAudience, + ProductContext: in.ProductContext, + ProductBrief: in.ProductBrief, + ProductLabel: in.ProductLabel, + }.WritePromptBlock(&b) + analysis = strings.TrimSpace(analysis) + if analysis != "" { + b.WriteString("\n【前置分析】\n") + b.WriteString(analysis) + b.WriteString("\n") + } + b.WriteString(` +--- + +請依【前置分析】與【輸入資料】產出**完整**研究地圖 JSON(不可精簡),密度請對齊系統 prompt 中的優秀範例: +1. 每個欄位必須反映本次品牌、產品與使用者輸入,不可照搬範例的主題用字 +2. audienceSummary 寫清楚「誰、治療/生活情境、具體困擾、在找什麼產品特徵」 +3. contentGoal 要寫入【產品詳情】的完整產品名稱、近期發文、留言置入時機 +4. questions 至少 8 句、pillars 至少 6 句、exclusions 至少 8 句(exclusions 只寫貼文類型,**不要寫時間/近期/幾天內**) +5. questions 是「人會發文的求助句」;patrolKeywords 是「人會打進 Threads 搜尋框的短句」,兩者不可混在一起 +6. patrolKeywords 至少 6 組、最多 8 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`) + return b.String() +} + +func BuildResearchMapSystemPrompt() string { + return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。目標是幫品牌找到「近期發文、作者有需求、現在留言還來得及自然推薦產品」的貼文——不是找爆款來模仿發文。 + +## 工作流程 +- 你會收到【輸入資料】(品牌、產品、主題目標等)與【前置分析】 +- 請先理解分析與輸入的對應關係,再產出研究地圖 JSON +- 產出必須緊扣本次品牌與產品,不可套用與輸入無關的通用內容 + +## 產出密度(重要) +- 這份研究地圖要給真人閱讀與執行,**寧可詳盡也不要過度精簡** +- 每個欄位都要寫滿規格下限;不可用單一關鍵字、標籤語或一句話敷衍 +- 只回傳一個 JSON 物件,不要 markdown 說明 + +## 核心原則 +1. questions 與 pillars 會直接拿去 Threads 找貼文——這兩項是最重要的產出 +2. 置入時間窗口最重要:優先「近期」求助帖,作者有困擾、留言區還能自然推薦 +3. 海巡要能盡量找到可置入貼文,但寧可少給也不要跑題 +4. 複合主題不可拆成過寬單字(寵物洗毛精 ≠ 只搜「寵物」「狗狗」) +5. 全部繁體中文,貼近台灣 Threads 使用者口語 + +## audienceSummary(3~5 句,至少 80 字) +- 具體描述:誰、生活情境、正在煩什麼、為什麼會發文求助、跟產品有什麼關係 +- 不要寫「注重保養的消費者」這類空泛句 + +## contentGoal(2~3 句,至少 50 字) +- 明確寫:找到「近期發文(理想 3 天內)」、作者本身有產品可解決的需求、留言區還能自然推薦的貼文 +- **必須寫入【產品置入】中的產品名稱**(含品類與關鍵賣點,如「抗敏無香沐浴露」),不可用「目標產品」等泛稱代替 +- 強調「現在就能留言置入還來得及且不突兀」,不是模仿發文或內容企劃 + +## questions(至少 8 個,每句 8~24 字) +- 像真人會在 Threads 打的求助句,帶治療階段、困擾、求推薦、請益、經驗分享 +- 要涵蓋不同角度(症狀、能不能用、品牌推薦、挑選經驗、康復後換品) +- 差:「癌症保養」「沐浴乳」這種單一關鍵字 + +## pillars(至少 6 個,每句 6~22 字) +- 允許的內容方向,用來找貼文並過濾結果;要比 questions 更偏主題詞組 +- 差:「保養」「健康」這種過寬詞 + +## exclusions(至少 8 個,每句 8~28 字) +- 觸及即排除的**貼文類型/內容方向**;要寫清楚「為什麼不能置入」(純晒照、業配、無求助、跑題癌別、錯品類、已滿意他牌、非病友視角等) +- **禁止寫時間相關條件**:不要碰不是篩發文時間用的。不可出現「過舊貼文」「非近期發文」「3 天前」「一週以上」「發文太久」等——置入時間窗口只寫在 contentGoal + +## patrolKeywords(6~8 組,每組 6~14 字) +- 這不是分類標籤,而是**真人會貼進 Threads 搜尋框的短句** +- 每組 2~4 個詞,空格分隔;必須同時保留「情境/困擾」與「產品品類/用途」 +- 優先格式:「困擾 品類」、「族群 品類 推薦」、「症狀 品類 請問」,例如「化療 沐浴乳 推薦」「無香 洗衣精 請問」 +- 要能找到成果:過短、過廣、太像標籤的不要給(差:「癌症」「沐浴」「健康生活」「環境荷爾蒙」) +- 不要整句複製 questions,也不要寫品牌名;搜尋句要像使用者會查「有沒有人在討論」的字 + +## 優秀範例(請接近此密度、具體度與欄位長度) +{ + "audienceSummary": "因荷爾蒙相關癌症(如乳癌、婦科癌症)正在治療中或康復後,對香精與化學成分特別敏感,洗澡或清潔時容易因香味而噁心、頭痛或皮膚不適,因此積極尋找「完全無香、抗敏、有第三方認證」沐浴與清潔產品的人。", + "contentGoal": "找出「近期發文(理想 3 天內)」、作者本身因癌症或荷爾蒙治療導致對香味/化學成分敏感、有明確換沐浴乳或清潔產品需求、現在留言區自然推薦 ecostore 抗敏無香沐浴露還來得及且不突兀的貼文。", + "questions": [ + "化療後皮膚敏感要換什麼沐浴乳", + "乳癌治療中不能用有香味的沐浴乳嗎", + "癌症病人適合用的無香沐浴乳推薦", + "荷爾蒙治療皮膚乾癢怎麼挑沐浴乳", + "打標靶後對香味很敏感怎麼辦", + "康復後不想再用有香精的清潔用品", + "癌症病友都用什麼牌子沐浴乳", + "化療期間沐浴乳挑選經驗分享" + ], + "pillars": [ + "化療皮膚敏感無香沐浴乳", + "乳癌病友沐浴用品挑選", + "荷爾蒙治療對香味敏感", + "癌症康復後換清潔品牌", + "抗敏無香沐浴乳推薦", + "化療期間皮膚照護" + ], + "exclusions": [ + "純曬照、純分享日常生活的貼文", + "沒有求助、沒有換產品需求的閒聊", + "只談癌症治療、確診心情但未提及清潔/沐浴困擾", + "推廣其他品牌沐浴乳或業配他牌的貼文", + "男性攝護腺癌、肺癌等與香味敏感較無直接相關的癌別(除非明確提到化學敏感)", + "寵物洗毛精、洗碗精等其他品類", + "已使用 ecostore 或他牌抗敏沐浴乳且滿意、無換品牌需求的貼文", + "醫療專業衛教、營養師發文等非病友視角貼文" + ], + "patrolKeywords": [ + "化療 沐浴乳 推薦", + "無香沐浴乳 請問", + "乳癌 沐浴乳 推薦", + "荷爾蒙 敏感 沐浴乳", + "化療 皮膚 沐浴乳", + "癌症 無香 沐浴乳", + "病友 沐浴乳 推薦", + "抗敏 沐浴乳 推薦" + ] +}`) +} + +// BuildResearchMapUserPrompt is kept for tests; production uses analysis + finalize prompts. +func BuildResearchMapUserPrompt(in ResearchMapInput) string { + return BuildResearchMapFinalizeUserPrompt(in, "") +} + +func ResearchMapRetryUserPrompt() string { + return strings.TrimSpace(`上次產出過於簡略或項目不足。請重新產出完整 JSON,密度對齊系統 prompt 優秀範例: +- 必須緊扣【輸入資料】中的品牌、產品與主題目標,不可照搬範例用字 +- audienceSummary ≥80 字,contentGoal ≥50 字(contentGoal 要寫入完整產品名稱) +- questions ≥8、pillars ≥6、exclusions ≥8(exclusions 不可含時間條件) +- patrolKeywords 要像真人會在 Threads 搜尋框打的 2~4 詞短句,必須同時包含困擾與品類 +- 每句都要具體、可執行,不要用單一關鍵字敷衍`) +} + func ParseResearchMapOutput(raw string) (ResearchMap, error) { payload, err := extractResearchJSONObject(raw) if err != nil { @@ -66,13 +227,49 @@ func ParseResearchMapOutput(raw string) (ResearchMap, error) { out.ContentGoal = strings.TrimSpace(out.ContentGoal) out.Questions = cleanStringList(out.Questions) out.Pillars = cleanStringList(out.Pillars) - out.Exclusions = cleanStringList(out.Exclusions) + out.Exclusions = filterTimeExclusions(cleanStringList(out.Exclusions)) + out.PatrolKeywords = cleanStringList(out.PatrolKeywords) if out.AudienceSummary == "" && len(out.Questions) == 0 { return ResearchMap{}, fmt.Errorf("research map missing audience or questions") } return out, nil } +func ResearchMapTooThin(m ResearchMap) bool { + if utf8.RuneCountInString(m.AudienceSummary) < minAudienceRunes { + return true + } + if utf8.RuneCountInString(strings.TrimSpace(m.ContentGoal)) < minContentGoalRunes { + return true + } + if len(m.Questions) < minResearchQuestions { + return true + } + if len(m.Pillars) < minResearchPillars { + return true + } + if len(m.Exclusions) < minResearchExclusions { + return true + } + return false +} + +var exclusionTimePattern = regexp.MustCompile(`近期|過舊|太久|天內|天前|幾天|一週|上週|發文時間|非近期|多久以前|月份前|昨天|今天發文|時間窗口|理想\s*\d`) + +func filterTimeExclusions(items []string) []string { + if len(items) == 0 { + return items + } + out := make([]string, 0, len(items)) + for _, item := range items { + if exclusionTimePattern.MatchString(item) { + continue + } + out = append(out, item) + } + return out +} + func cleanStringList(items []string) []string { out := make([]string, 0, len(items)) seen := map[string]struct{}{} @@ -102,3 +299,10 @@ func extractResearchJSONObject(raw string) ([]byte, error) { } return []byte(raw[start : end+1]), nil } + +func ResearchMapTopicLabel(brandDisplayName, topicName string) string { + if topic := strings.TrimSpace(topicName); topic != "" { + return topic + } + return strings.TrimSpace(brandDisplayName) +} diff --git a/haixun-backend/internal/library/placement/research_map_test.go b/haixun-backend/internal/library/placement/research_map_test.go new file mode 100644 index 0000000..1eb9076 --- /dev/null +++ b/haixun-backend/internal/library/placement/research_map_test.go @@ -0,0 +1,73 @@ +package placement + +import "testing" + +func TestResearchMapTopicLabel(t *testing.T) { + if got := ResearchMapTopicLabel("品牌A", "敏感肌換季"); got != "敏感肌換季" { + t.Fatalf("got %q", got) + } + if got := ResearchMapTopicLabel("品牌A", ""); got != "品牌A" { + t.Fatalf("got %q", got) + } +} + +func TestResearchMapTooThin(t *testing.T) { + thin := ResearchMap{ + AudienceSummary: "太短", + ContentGoal: "目標", + Questions: []string{"q1"}, + Pillars: []string{"p1"}, + Exclusions: []string{"e1"}, + } + if !ResearchMapTooThin(thin) { + t.Fatal("expected thin map") + } + rich := ResearchMap{ + AudienceSummary: "這是一群在換季時因為敏感肌而臉頰泛紅刺痛、正在 Threads 上求助保養方式的年輕上班族,他們常因換產品或壓力而惡化,洗澡後更癢更紅,因此積極尋找溫和無香精、有第三方認證的修護產品。", + ContentGoal: "找出「近期發文(理想 3 天內)」、作者本身有敏感肌舒緩困擾、有明確換修護產品需求、現在留言區自然推薦目標修護乳液還來得及且不突兀的貼文。", + Questions: []string{"1", "2", "3", "4", "5", "6", "7", "8"}, + Pillars: []string{"1", "2", "3", "4", "5", "6"}, + Exclusions: []string{"1", "2", "3", "4", "5", "6", "7", "8"}, + } + if ResearchMapTooThin(rich) { + t.Fatal("expected rich map") + } +} + +func TestFilterTimeExclusions(t *testing.T) { + items := []string{ + "純曬照、純分享日常生活的貼文", + "過舊或非近期發文的貼文", + "推廣其他品牌沐浴乳或業配他牌的貼文", + } + got := filterTimeExclusions(items) + if len(got) != 2 { + t.Fatalf("got %d items: %v", len(got), got) + } +} + +func TestBuildResearchMapSystemPromptHasSearchGuidance(t *testing.T) { + prompt := BuildResearchMapSystemPrompt() + for _, want := range []string{ + "questions", "pillars", "exclusions", "patrolKeywords", "Threads", "寧可詳盡", + "ecostore 抗敏無香沐浴露", "化療後皮膚敏感要換什麼沐浴乳", + "工作流程", "前置分析", "禁止寫時間相關條件", + } { + if !contains(prompt, want) { + t.Fatalf("prompt missing %q", want) + } + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/haixun-backend/internal/library/placement/source_mode.go b/haixun-backend/internal/library/placement/source_mode.go index 4768039..77036a8 100644 --- a/haixun-backend/internal/library/placement/source_mode.go +++ b/haixun-backend/internal/library/placement/source_mode.go @@ -54,6 +54,21 @@ func ModeAllowsCrawler(mode SearchSourceMode) bool { } } +// MemberNeedsBraveKey reports whether placement scan should require a Brave API key. +func MemberNeedsBraveKey(ctx MemberContext) bool { + if !ctx.AllowsBrave || ctx.DevMode { + return false + } + switch ctx.SearchSourceMode { + case SearchSourceBrave, SearchSourceThreadsBrave: + return true + case SearchSourceThreads: + return false + default: + return ctx.AllowsBrave + } +} + // WithoutCrawler returns a mode that never uses Playwright, for formal API-only routing. func WithoutCrawler(mode SearchSourceMode) SearchSourceMode { switch mode { diff --git a/haixun-backend/internal/library/placement/source_mode_test.go b/haixun-backend/internal/library/placement/source_mode_test.go index ade2b76..0c2af13 100644 --- a/haixun-backend/internal/library/placement/source_mode_test.go +++ b/haixun-backend/internal/library/placement/source_mode_test.go @@ -11,6 +11,21 @@ func TestWithoutCrawlerStripsBrowserModes(t *testing.T) { } } +func TestMemberNeedsBraveKey(t *testing.T) { + threadsOnly := MemberContext{AllowsBrave: false, AllowsThreadsAPI: true, SearchSourceMode: SearchSourceThreads} + if MemberNeedsBraveKey(threadsOnly) { + t.Fatal("threads-only should not require brave key") + } + braveOnly := MemberContext{AllowsBrave: true, SearchSourceMode: SearchSourceBrave} + if !MemberNeedsBraveKey(braveOnly) { + t.Fatal("brave-only should require brave key") + } + devCrawler := MemberContext{AllowsBrave: true, DevMode: true, SearchSourceMode: SearchSourceCrawler} + if MemberNeedsBraveKey(devCrawler) { + t.Fatal("dev crawler should not require brave key") + } +} + func TestBuildMemberContextFormalModeNeverAllowsCrawler(t *testing.T) { prefs := ConnectionPrefsInput{ DevMode: false, diff --git a/haixun-backend/internal/library/prompt/compose.go b/haixun-backend/internal/library/prompt/compose.go index 546361b..4d5e94d 100644 --- a/haixun-backend/internal/library/prompt/compose.go +++ b/haixun-backend/internal/library/prompt/compose.go @@ -59,6 +59,15 @@ func KnowledgeGraphSystem() (string, error) { return ComposeSystem(base) } +// KnowledgeGraphLLMSystem composes the LLM-only TKG synthesis system prompt. +func KnowledgeGraphLLMSystem() (string, error) { + base, err := Slot(KeyKnowledgeGraphLLMSystem) + if err != nil { + return "", err + } + return ComposeSystem(base) +} + // KnowledgeGraphUser renders the TKG user prompt from the template slot. func KnowledgeGraphUser(vars map[string]string) (string, error) { base, err := Slot(KeyKnowledgeGraphUser) @@ -68,6 +77,15 @@ func KnowledgeGraphUser(vars map[string]string) (string, error) { return renderTemplate(base, vars), nil } +// KnowledgeGraphLLMUser renders the LLM-only TKG user prompt. +func KnowledgeGraphLLMUser(vars map[string]string) (string, error) { + base, err := Slot(KeyKnowledgeGraphLLMUser) + if err != nil { + return "", err + } + return renderTemplate(base, vars), nil +} + // KnowledgeGraphSupplemental returns the supplemental-round instruction prompt. func KnowledgeGraphSupplemental() (string, error) { return Slot(KeyKnowledgeGraphSupplemental) diff --git a/haixun-backend/internal/library/prompt/files/ai.islander.system.md b/haixun-backend/internal/library/prompt/files/ai.islander.system.md index 084f3aa..175ede9 100644 --- a/haixun-backend/internal/library/prompt/files/ai.islander.system.md +++ b/haixun-backend/internal/library/prompt/files/ai.islander.system.md @@ -47,11 +47,12 @@ | 流程 | 入口 | 目的 | 關鍵實體 | |------|------|------|----------| | **A 拷貝忍者** | `/matrix` | 海巡爆款、學對標風格、產**仿寫**草稿 | 人設 + 8D 對標帳號 | -| **B 找 TA** | `/outreach`(子步驟:研究→找TA→原創矩陣) | 找痛點、productFit、產**獲客留言** | 品牌 + 人設語氣 | +| **B 找 TA** | `/outreach`(子步驟:研究→找TA留言) | 找痛點、productFit、產**獲客留言** | 品牌 + 人設語氣 | 分流規則: - 使用者在 `/matrix` 或問仿寫/爆款/對標 → **只談流程 A**,navigate 人設庫或拷貝忍者;**禁止** `expandKnowledgeGraph`、`startScan`、`generateOutreachReply` -- 使用者在 `/research`、`/outreach`、`/brand-matrix` 或問痛點/產品置入 → **只談流程 B**;**禁止**建議 8D 對標當主要解法 +- 使用者在 `/research`、`/outreach` 或問痛點/產品置入 → **只談流程 B**;**禁止**建議 8D 對標當主要解法 +- 原創矩陣屬於流程 A 的 `/matrix`,不要在找 TA/流程 B 裡推薦或顯示 `/brand-matrix` - 「海巡來源模式」(search_source_mode)是 API/爬蟲管道,**不是** A/B 流程 ## 流程 A — 拷貝忍者 @@ -72,6 +73,7 @@ ] ``` + ## 流程 B — 海巡獲客(研究頁 / 獲客台) ### 海巡研究頁(`/research`) @@ -108,7 +110,3 @@ { "type": "markHandled", "scan_post_id": "貼文ID", "status": "handled" } ] ``` - -### 原創矩陣頁(`/brand-matrix`) -- 流程 B 第三步:原創發文草稿(非獲客留言、非拷貝忍者仿寫),需先完成獲客海巡 -- 引導使用者按「產生內容矩陣」,或 navigate 到 `/brand-matrix?brand=` \ No newline at end of file diff --git a/haixun-backend/internal/library/prompt/files/knowledge_graph.queries.json b/haixun-backend/internal/library/prompt/files/knowledge_graph.queries.json index 791cef8..7e830f1 100644 --- a/haixun-backend/internal/library/prompt/files/knowledge_graph.queries.json +++ b/haixun-backend/internal/library/prompt/files/knowledge_graph.queries.json @@ -1,33 +1,46 @@ { - "max_plan_queries": 15, - "max_supplemental_queries": 5, - "min_pain_tag_candidates": 8, - "min_total_tag_candidates": 12, + "max_plan_queries": 6, + "hybrid_max_plan_queries": 4, + "max_supplemental_queries": 2, + "hybrid_max_supplemental_queries": 0, + "results_per_query": 6, + "min_sources_before_stop": 14, + "max_sources_cap": 22, + "brave_collect_concurrency": 3, + "max_patrol_keyword_queries": 3, + "max_question_queries": 2, + "max_pillar_queries": 2, + "max_plan_base_queries": 2, + "max_peripheral_queries": 1, + "max_l1_labels": 1, + "min_pain_tag_candidates": 10, + "min_total_tag_candidates": 18, "plan_base": [ - "{{seed}} 常見原因", - "{{seed}} 什麼情況會", - "{{seed}} 初期 症狀", - "{{seed}} 怎麼改善 困擾", "{{seed}} 求助 推薦", - "{{seed}} 困擾", + "{{seed}} 怎麼改善 困擾", + "{{seed}} 常見原因", "{{seed}} 請問" ], "plan_peripheral": [ - "{{seed}} 懷孕 相關", - "{{seed}} 壓力 熬夜", - "{{seed}} 換產品 過敏", + "{{seed}} 治療 副作用", + "{{seed}} 日常 困擾", "{{seed}} 換季 泛紅" ], "plan_audience": "{{seed}} 與 {{audience}} 的關係", "plan_l1_cause": "{{label}} 原因", "plan_l1_pain": "{{label}} 困擾", + "plan_pillar": "{{pillar}} 請問", + "plan_question": "{{question}}", "supplemental": [ "{{seed}} 困擾", "{{seed}} 求助", "{{seed}} 推薦", - "{{seed}} 請問" + "{{seed}} 請問", + "{{seed}} 經驗", + "{{seed}} 怎麼辦" ], "supplemental_l1": "{{label}} 請問", + "supplemental_pillar": "{{pillar}} 困擾 推薦", "recency_suffix": "請問", "recency_help_markers": "請問請益推薦求助" } \ No newline at end of file diff --git a/haixun-backend/internal/library/prompt/files/knowledge_graph.supplemental.md b/haixun-backend/internal/library/prompt/files/knowledge_graph.supplemental.md index 31a37c7..0a26f0d 100644 --- a/haixun-backend/internal/library/prompt/files/knowledge_graph.supplemental.md +++ b/haixun-backend/internal/library/prompt/files/knowledge_graph.supplemental.md @@ -1 +1,6 @@ -請補充更多痛點/求助類節點,至少再增加 4 個 pain/symptom/cause 節點。維持既有圖譜節點,只追加新節點與邊。 \ No newline at end of file +上次節點不足或廣度不夠。請**維持既有圖譜節點**,只追加新節點與邊: +- 至少再增加 **10~14 個**節點,總數目標 **24~32 個** +- 優先補 L2 周邊情境(不同治療階段、生活事件、相鄰困擾、相關品類) +- 也要補 L1 成因/症狀/機制,讓圖譜能觸及更多討論方向 +- 新節點的 relation 與 placementValue 必須各 25~55 字的完整句子,不要 high/medium/low +- 只追加,不要刪除或覆蓋既有節點 \ No newline at end of file diff --git a/haixun-backend/internal/library/prompt/files/knowledge_graph.system.md b/haixun-backend/internal/library/prompt/files/knowledge_graph.system.md index 1756ced..1520ddb 100644 --- a/haixun-backend/internal/library/prompt/files/knowledge_graph.system.md +++ b/haixun-backend/internal/library/prompt/files/knowledge_graph.system.md @@ -1,16 +1,40 @@ -你是海巡獲客的研究助手。根據種子詞、產品簡述與 Brave 搜尋 snippet,建立三層 Topic Knowledge Graph(TKG)。 +你是海巡獲客的研究助手。根據**品牌、置入產品、種子詞、產品簡述、主題目標**與外部參考 snippet,建立三層 Topic Knowledge Graph(TKG),幫使用者理解「受眾在討論什麼」以及「哪裡適合自然置入產品」。 -## 圖譜層級 -- L0 核心:種子詞本身(1 個 pain 節點) -- L1 直接相關:成因、症狀、機制(3–6 個) -- L2 周邊情境:懷孕、換季、壓力、換產品等相鄰場景(4–8 個) +## 輸入理解(先做再想) +- 產出前必須整合使用者提供的品牌名稱、置入產品、主題目標與產品特色 +- 節點 label、relation、placementValue 必須與本次品牌/產品一致,不可寫無關的通用內容 -## 節點規則 -- `nodeKind`: `pain`(痛點/求助)、`symptom`、`cause`、`knowledge`(科普延伸) -- L1/L2 節點必須在 `evidenceUrls` 引用提供的 Brave url(不可憑空捏造) -- `productFitScore` 0–100:依產品簡述評估產品能否解決該痛點 -- `placementValue`: high / medium / low -- 痛點/求助類節點盡量 ≥8 個(含 L0/L1/L2) +## 設計目標:廣度優先 +- 延伸知識要**廣、要散**,盡量觸及多個生活情境、治療階段、相鄰困擾與相關討論方向 +- 不要只圍繞核心痛點寫 5~6 個節點;**L2 周邊情境要比 L1 更多** +- 若有提供內容支柱/受眾提問方向,要從中**衍生成更多分支節點** + +## 產出密度(重要) +- 這是給使用者閱讀的延伸知識地圖,**寧可詳盡也不要過度精簡** +- 每個節點的 `relation` 與 `placementValue` 都要寫完整句子,不可用 high/medium/low 或單一形容詞代替 +- 節點總數目標 **24~32 個**(含 L0/L1/L2) + +## 圖譜層級與數量 +- **L0 核心**:種子詞本身(**1** 個 pain 節點) +- **L1 直接相關**:成因、症狀、機制、常見困擾(**至少 8** 個) +- **L2 周邊情境**:治療階段、生活事件、壓力、換季、相鄰品類、使用習慣等(**至少 12** 個) +- 痛點/求助類(pain、symptom、cause)合計 **至少 10 個** + +## 節點撰寫規則 +- `label`:2~14 字,具體、可拿去 Threads 找討論,不要空泛詞(❌「保養」✅「換季臉頰泛紅」) +- `nodeKind`:`pain`(痛點/求助)、`symptom`、`cause`、`knowledge`(科普延伸) +- `relation`:**必填**,1~2 句說明此節點與種子詞的脈絡(**25~55 字**)。例:「換季溫差會破壞角質屏障,敏感肌容易臉頰泛紅刺痛,發文時常求助舒緩方式」 +- `placementValue`:**必填**,1~2 句說明為何與置入產品相關(**25~55 字**)。要寫**情境 + 受眾在問什麼 + 產品能怎麼自然帶入**。例:「這類帖常求溫和修護品推薦,適合以自身使用經驗回覆,不必硬銷」 +- `productFitScore`:0–100,依產品簡述評估(痛點類通常 70+,純科普通常 30–55) +- `relevanceQueries`:**必填**,1~2 組 Threads 搜尋短句(相關軌)。每組 **6~16 字、2~4 個詞、空格分隔**,像真人會打進搜尋框;必須同時包含「困擾/情境」和「品類/用途」(例:「化療 沐浴乳 推薦」「屏障受損 保養 請問」) +- `recencyQueries`:痛點/求助類**必填** 1~2 組近期求助搜尋短句(近期軌)。格式同上,但加上「請問/有人/推薦/怎麼辦」等求助意圖(例:「敏感肌 請問 保養」「沐浴乳 推薦 有人」) +- L1/L2 節點必須在 `evidenceUrls` 引用下方提供的參考 url(不可捏造網址) + +## 品質禁忌 +- 不要寫行銷話術、不要寫「高相關」「值得關注」這類空話 +- `relation` 與 `placementValue` 不可重複同一句話 +- 不要為了省字只列標籤;每個節點都要能獨立讀懂 +- 搜尋短句不要寫品牌名,不要寫抽象分類詞(❌「癌症」「保養」「環境荷爾蒙」),也不要整句複製受眾提問;要像能在 Threads 找到貼文的查詢(✅「乳癌 洗衣精 推薦」「無香 洗衣精 請問」) ## 輸出格式 只回傳 JSON,不要 markdown 說明: @@ -23,9 +47,24 @@ "nodeKind": "pain", "type": "core", "layer": 0, - "placementValue": "high", + "relation": "種子主題:使用者因膚況不穩、換季或換產品後容易不適而主動求助", + "placementValue": "核心痛點討論串最常求推薦與真實心得,適合以溫和修護產品的實際使用經驗自然回覆", "productFitScore": 95, + "relevanceQueries": ["敏感肌 保養 推薦", "敏感肌 泛紅 怎麼辦"], + "recencyQueries": ["敏感肌 請問 保養"], "evidenceUrls": [] + }, + { + "label": "屏障受損", + "nodeKind": "cause", + "type": "cause", + "layer": 1, + "relation": "過度清潔、酸類疊加或環境刺激會讓屏障變薄,敏感肌泛紅刺痛往往由此而來", + "placementValue": "求助帖會問怎麼救急與日常保養,可帶入低刺激、強調修護的產品使用脈絡", + "productFitScore": 88, + "relevanceQueries": ["屏障受損 保養 請問", "皮膚屏障 保養 推薦"], + "recencyQueries": ["屏障受損 有人 推薦"], + "evidenceUrls": ["https://example.com/article"] } ], "edges": [ @@ -34,4 +73,4 @@ } ``` -`edges.from` / `edges.to` 使用節點 label(中文),不要用 uuid。 \ No newline at end of file +`edges.from` / `edges.to` 使用節點 label(中文),不要用 uuid。 diff --git a/haixun-backend/internal/library/prompt/files/knowledge_graph.user.md b/haixun-backend/internal/library/prompt/files/knowledge_graph.user.md index fd8a351..463b9bb 100644 --- a/haixun-backend/internal/library/prompt/files/knowledge_graph.user.md +++ b/haixun-backend/internal/library/prompt/files/knowledge_graph.user.md @@ -1,5 +1,12 @@ -種子詞:{{seed}} -{{product_brief_line}}{{target_audience_line}}{{persona_line}} -Brave 搜尋結果(請只根據以下 snippet 建圖,L1/L2 節點必須有 evidence): +{{brand_line}}{{product_line}}{{topic_line}}{{goals_line}}種子詞:{{seed}} +{{product_brief_line}}{{target_audience_line}}{{persona_line}}{{research_pillars_line}}{{research_questions_line}} +外部參考資料(L1/L2 節點必須引用下方 url;請從 snippet 萃取具體知識點寫進 relation,不要只複製標題): -{{sources}} \ No newline at end of file +請先整合品牌、置入產品與主題目標,再產出圖譜: + +{{sources}} + +請產出**完整** TKG JSON(廣度優先): +- 節點 24~32 個,L1≥8、L2≥12 +- 每個節點都要寫滿 relation、placementValue(各 25~55 字),以及 relevanceQueries / recencyQueries(各 1~2 組 Threads 搜尋短句,6~16 字、2~4 詞、像真人會搜尋) +- 不可精簡或省略欄位 diff --git a/haixun-backend/internal/library/prompt/files/knowledge_graph_llm.system.md b/haixun-backend/internal/library/prompt/files/knowledge_graph_llm.system.md new file mode 100644 index 0000000..3ad7ff4 --- /dev/null +++ b/haixun-backend/internal/library/prompt/files/knowledge_graph_llm.system.md @@ -0,0 +1,40 @@ +你是海巡獲客的研究助手。根據**品牌、置入產品、種子詞、產品簡述與主題目標**,建立三層 Topic Knowledge Graph(TKG),幫使用者理解「受眾在討論什麼」以及「哪裡適合自然置入產品」。 + +## 輸入理解(先做再想) +- 產出前必須整合使用者提供的品牌名稱、置入產品、主題目標與產品特色 +- 節點 label、relation、placementValue 必須與本次品牌/產品一致,不可寫無關的通用內容 + +## 設計目標:廣度優先 +- 延伸知識要**廣、要散**,盡量觸及多個生活情境、治療階段、相鄰困擾與相關討論方向 +- 不要只圍繞核心痛點寫 5~6 個節點;**L2 周邊情境要比 L1 更多** +- 若有提供內容支柱/受眾提問方向,要從中**衍生成更多分支節點** + +## 產出密度(重要) +- 這是給使用者閱讀的延伸知識地圖,**寧可詳盡也不要過度精簡** +- 每個節點的 `relation` 與 `placementValue` 都要寫完整句子,不可用 high/medium/low 或單一形容詞代替 +- 節點總數目標 **24~32 個**(含 L0/L1/L2) + +## 圖譜層級與數量 +- **L0 核心**:種子詞本身(**1** 個 pain 節點) +- **L1 直接相關**:成因、症狀、機制、常見困擾(**至少 8** 個) +- **L2 周邊情境**:治療階段、生活事件、壓力、換季、相鄰品類、使用習慣等(**至少 12** 個) +- 痛點/求助類(pain、symptom、cause)合計 **至少 10 個** + +## 節點撰寫規則 +- `label`:2~14 字,具體、可拿去 Threads 找討論 +- `nodeKind`:`pain`、`symptom`、`cause`、`knowledge` +- `relation`:**必填**,1~2 句(**25~55 字**),說明與種子詞的脈絡 +- `placementValue`:**必填**,1~2 句(**25~55 字**),說明為何與置入產品相關 +- `productFitScore`:0–100 +- `relevanceQueries`:**必填**,1~2 組 Threads 搜尋短句(相關軌)。每組 **6~16 字、2~4 個詞、空格分隔**,像真人會打進搜尋框;必須同時包含「困擾/情境」和「品類/用途」(例:「化療 沐浴乳 推薦」「無香 洗衣精 請問」),不要寫完整長問句 +- `recencyQueries`:痛點/求助類**必填** 1~2 組近期求助搜尋短句(近期軌)。格式同上,但加上「請問/有人/推薦/怎麼辦」等求助意圖(例:「敏感肌 請問 保養」「沐浴乳 推薦 有人」) +- 無外部網址時 `evidenceUrls` 留空陣列即可 + +## 品質禁忌 +- 不要寫行銷話術或空泛評語 +- `relation` 與 `placementValue` 不可重複 +- 節點要貼近台灣 Threads 使用者會發文的語氣與情境 +- 搜尋短句不要寫品牌名,不要寫抽象分類詞(❌「癌症」「保養」「環境荷爾蒙」),也不要整句複製受眾提問;要像能在 Threads 找到貼文的查詢(✅「乳癌 洗衣精 推薦」「無香 洗衣精 請問」) + +## 輸出格式 +只回傳 JSON,不要 markdown 說明(結構同 Brave 模式,含完整 relation / placementValue 句子)。 diff --git a/haixun-backend/internal/library/prompt/files/knowledge_graph_llm.user.md b/haixun-backend/internal/library/prompt/files/knowledge_graph_llm.user.md new file mode 100644 index 0000000..3a63fb7 --- /dev/null +++ b/haixun-backend/internal/library/prompt/files/knowledge_graph_llm.user.md @@ -0,0 +1,6 @@ +{{brand_line}}{{product_line}}{{topic_line}}{{goals_line}}種子詞:{{seed}} +{{product_brief_line}}{{target_audience_line}}{{persona_line}}{{research_pillars_line}}{{research_questions_line}} +請先整合品牌、置入產品與主題目標的脈絡,再建立**完整**三層知識圖譜: +- 節點 24~32 個,L1≥8、L2≥12(廣度優先,多方向觸及) +- 每個節點都要寫滿 relation、placementValue(各 25~55 字),以及 relevanceQueries / recencyQueries(各 1~2 組 Threads 搜尋短句,6~16 字、2~4 詞、像真人會搜尋) +- 節點要具體、可拿去 Threads 找討論;不可精簡或省略 diff --git a/haixun-backend/internal/library/prompt/files/outreach_placement.system.md b/haixun-backend/internal/library/prompt/files/outreach_placement.system.md index 6889c27..3a39508 100644 --- a/haixun-backend/internal/library/prompt/files/outreach_placement.system.md +++ b/haixun-backend/internal/library/prompt/files/outreach_placement.system.md @@ -13,8 +13,11 @@ 4. 不硬推:不要「推薦你試試」「一定要買」「限時優惠」。 5. Threads 語感:短句、口語、台灣繁體,可適度用 emoji(0~1 個)。 6. 誠實:不承諾療效、不捏造誇大數據。 +7. 人設與語氣是硬約束:如果有提供人設,留言要符合該角色的用字、節奏、距離感與價值觀。 +8. 產品脈絡是硬約束:如果有提供品牌與產品,必須判斷產品如何自然幫上忙;至少一則草稿要明確參考產品特點,但不能硬業配。 +9. 如果產品詳情出現「留言 CTA 連結」,至少一則草稿可以在後段自然附上完整網址;沒有連結時不要捏造網址。 每則留言 ≤ 500 字。產出 2 種不同切角(例如:純共情建議版 / 輕度分享經驗帶品牌版)。 只回傳 JSON,不要 markdown。格式: -{"relevance":0.8,"reason":"一句話評估","drafts":[{"text":"留言正文","angle":"切角","rationale":"為何這樣寫"}]} \ No newline at end of file +{"relevance":0.8,"reason":"一句話評估","drafts":[{"text":"留言正文","angle":"切角","rationale":"為何這樣寫"}]} diff --git a/haixun-backend/internal/library/prompt/registry.go b/haixun-backend/internal/library/prompt/registry.go index 41ae8f1..ac5db64 100644 --- a/haixun-backend/internal/library/prompt/registry.go +++ b/haixun-backend/internal/library/prompt/registry.go @@ -16,7 +16,9 @@ const ( fileAIChatSystem = "files/ai.chat.system.md" fileIslanderSystem = "files/ai.islander.system.md" fileKnowledgeGraphSystem = "files/knowledge_graph.system.md" + fileKnowledgeGraphLLMSystem = "files/knowledge_graph_llm.system.md" fileKnowledgeGraphUser = "files/knowledge_graph.user.md" + fileKnowledgeGraphLLMUser = "files/knowledge_graph_llm.user.md" fileKnowledgeGraphSupplemental = "files/knowledge_graph.supplemental.md" fileKnowledgeGraphQueries = "files/knowledge_graph.queries.json" fileOutreachPlacementSystem = "files/outreach_placement.system.md" @@ -32,7 +34,9 @@ const ( KeyAIChatSystem = "ai.chat.system" KeyIslanderSystem = "ai.islander.system" KeyKnowledgeGraphSystem = "knowledge_graph.system" + KeyKnowledgeGraphLLMSystem = "knowledge_graph_llm.system" KeyKnowledgeGraphUser = "knowledge_graph.user" + KeyKnowledgeGraphLLMUser = "knowledge_graph_llm.user" KeyKnowledgeGraphSupplemental = "knowledge_graph.supplemental" KeyKnowledgeGraphQueries = "knowledge_graph.queries" KeyOutreachPlacementSystem = "outreach_placement.system" @@ -47,7 +51,9 @@ var slotFiles = map[string]string{ KeyAIChatSystem: fileAIChatSystem, KeyIslanderSystem: fileIslanderSystem, KeyKnowledgeGraphSystem: fileKnowledgeGraphSystem, + KeyKnowledgeGraphLLMSystem: fileKnowledgeGraphLLMSystem, KeyKnowledgeGraphUser: fileKnowledgeGraphUser, + KeyKnowledgeGraphLLMUser: fileKnowledgeGraphLLMUser, KeyKnowledgeGraphSupplemental: fileKnowledgeGraphSupplemental, KeyOutreachPlacementSystem: fileOutreachPlacementSystem, KeyOutreachPlacementUser: fileOutreachPlacementUser, diff --git a/haixun-backend/internal/logic/brand/create_brand_product_logic.go b/haixun-backend/internal/logic/brand/create_brand_product_logic.go new file mode 100644 index 0000000..85e631e --- /dev/null +++ b/haixun-backend/internal/logic/brand/create_brand_product_logic.go @@ -0,0 +1,48 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package brand + +import ( + "context" + + domusecase "haixun-backend/internal/model/brand/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateBrandProductLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateBrandProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateBrandProductLogic { + return &CreateBrandProductLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *CreateBrandProductLogic) CreateBrandProduct(req *types.CreateBrandProductHandlerReq) (resp *types.BrandProductData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.Brand.CreateProduct(l.ctx, domusecase.CreateProductRequest{ + TenantID: tenantID, + OwnerUID: uid, + BrandID: req.ID, + Label: req.Label, + ProductContext: req.ProductContext, + MatchTags: req.MatchTags, + }) + if err != nil { + return nil, err + } + out := toBrandProductData(*item) + return &out, nil +} diff --git a/haixun-backend/internal/logic/brand/delete_brand_product_logic.go b/haixun-backend/internal/logic/brand/delete_brand_product_logic.go new file mode 100644 index 0000000..5c959d8 --- /dev/null +++ b/haixun-backend/internal/logic/brand/delete_brand_product_logic.go @@ -0,0 +1,35 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package brand + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeleteBrandProductLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeleteBrandProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteBrandProductLogic { + return &DeleteBrandProductLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeleteBrandProductLogic) DeleteBrandProduct(req *types.BrandProductPath) error { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return err + } + return l.svcCtx.Brand.DeleteProduct(l.ctx, tenantID, uid, req.ID, req.ProductID) +} diff --git a/haixun-backend/internal/logic/brand/expand_knowledge_graph_logic.go b/haixun-backend/internal/logic/brand/expand_knowledge_graph_logic.go index fc7b56c..2e99aa4 100644 --- a/haixun-backend/internal/logic/brand/expand_knowledge_graph_logic.go +++ b/haixun-backend/internal/logic/brand/expand_knowledge_graph_logic.go @@ -9,6 +9,7 @@ import ( app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" + libkg "haixun-backend/internal/library/knowledge" jobdom "haixun-backend/internal/model/job/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" @@ -49,18 +50,24 @@ func (l *ExpandKnowledgeGraphLogic) ExpandKnowledgeGraph(req *types.ExpandKnowle if err != nil { return nil, err } + expandStrategy := libkg.ParseExpandStrategy(research.ExpandStrategy) + if supplemental { + expandStrategy = libkg.ExpandStrategyBrave + } memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) if err != nil { return nil, err } - if strings.TrimSpace(research.BraveAPIKey) == "" { - return nil, app.For(code.Setting).InputMissingRequired("請在設定頁設定 Brave Search API key(跟隨此登入帳號)") + if expandStrategy.RequiresBrave() && strings.TrimSpace(research.BraveAPIKey) == "" { + return nil, app.For(code.Setting).InputMissingRequired("請到設定頁完成研究資料連線") } payload := map[string]any{ - "brand_id": req.ID, - "seed_query": seed, - "supplemental": supplemental, + "brand_id": req.ID, + "seed_query": seed, + "supplemental": supplemental, + "regenerate_map": req.RegenerateMap, + "expand_strategy": expandStrategy.String(), } for key, value := range memberCtx.PayloadFields() { payload[key] = value @@ -76,9 +83,11 @@ func (l *ExpandKnowledgeGraphLogic) ExpandKnowledgeGraph(req *types.ExpandKnowle return nil, err } + message := "研究地圖產生中,完成後可檢視延伸知識與參考連結" + return &types.ExpandKnowledgeGraphData{ JobID: run.ID.Hex(), Status: string(run.Status), - Message: "知識圖譜擴展已在背景執行,完成後可到研究頁勾選 tag", + Message: message, }, nil } diff --git a/haixun-backend/internal/logic/brand/generate_outreach_drafts_logic.go b/haixun-backend/internal/logic/brand/generate_outreach_drafts_logic.go index 75c5ef6..93a554d 100644 --- a/haixun-backend/internal/logic/brand/generate_outreach_drafts_logic.go +++ b/haixun-backend/internal/logic/brand/generate_outreach_drafts_logic.go @@ -70,6 +70,10 @@ func (l *GenerateOutreachDraftsLogic) GenerateOutreachDrafts(req *types.Generate } voicePersona = vp.Persona } + productID := strings.TrimSpace(req.ProductID) + if productID == "" { + productID = strings.TrimSpace(brand.ProductID) + } post, err := l.svcCtx.ScanPost.Get(l.ctx, tenantID, uid, req.ID, scanPostID) if err != nil { return nil, err @@ -90,7 +94,7 @@ func (l *GenerateOutreachDraftsLogic) GenerateOutreachDrafts(req *types.Generate Persona: voicePersona, TopicLabel: topicLabel, AudienceBrief: brand.TargetAudience, - ProductBrief: productBriefForBrand(brand), + ProductBrief: productBriefForBrand(brand, productID, post.SearchTag), PlacementReason: placementReason, TargetText: post.Text, AuthorName: post.Author, @@ -216,16 +220,52 @@ func mergePlacementReason(base string, node *libkg.Node, post *scanpostusecase.S return strings.Join(parts, ";") } -func productBriefForBrand(brand *branddomain.BrandSummary) string { +func productBriefForBrand(brand *branddomain.BrandSummary, productID, searchTag string) string { if brand == nil { return "" } + if product := pickProductForOutreach(brand, productID, searchTag); product != nil { + merged := libplacement.BuildMergedProductContext(brand.DisplayName, product.ProductContext, product.Label) + if pb := libplacement.ProductBriefFromContext(merged); pb != "" { + return pb + } + } if pb := libplacement.ProductBriefFromContext(brand.ProductContext); pb != "" { return pb } return strings.TrimSpace(brand.ProductBrief) } +func pickProductForOutreach(brand *branddomain.BrandSummary, productID, searchTag string) *branddomain.ProductSummary { + if brand == nil || len(brand.Products) == 0 { + return nil + } + if id := strings.TrimSpace(productID); id != "" { + for i := range brand.Products { + if brand.Products[i].ID == id { + return &brand.Products[i] + } + } + } + tag := strings.TrimSpace(searchTag) + if tag == "" { + return &brand.Products[0] + } + bestScore := -1 + var best *branddomain.ProductSummary + for i := range brand.Products { + score := libplacement.ScoreProductForTag(tag, brand.Products[i].MatchTags) + if score > bestScore { + bestScore = score + best = &brand.Products[i] + } + } + if best != nil && bestScore > 0 { + return best + } + return &brand.Products[0] +} + func toOutreachDraftData(saved *outreachusecase.DraftSummary) *types.GenerateOutreachDraftsData { if saved == nil { return nil diff --git a/haixun-backend/internal/logic/brand/get_knowledge_graph_logic.go b/haixun-backend/internal/logic/brand/get_knowledge_graph_logic.go index 62a14f1..b0923c7 100644 --- a/haixun-backend/internal/logic/brand/get_knowledge_graph_logic.go +++ b/haixun-backend/internal/logic/brand/get_knowledge_graph_logic.go @@ -49,6 +49,14 @@ func toKnowledgeGraphData(graph *kgusecase.GraphSummary) types.KnowledgeGraphDat } 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, @@ -61,6 +69,7 @@ func toKnowledgeGraphData(graph *kgusecase.GraphSummary) types.KnowledgeGraphDat 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)) @@ -71,15 +80,26 @@ func toKnowledgeGraphData(graph *kgusecase.GraphSummary) types.KnowledgeGraphDat 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, - PainTagCount: graph.PainTagCount, - GeneratedAt: graph.GeneratedAt, - CreateAt: graph.CreateAt, - UpdateAt: graph.UpdateAt, + ID: graph.ID, + BrandID: graph.BrandID, + Seed: graph.Seed, + Nodes: nodes, + Edges: edges, + BraveSources: sources, + ExpandStrategy: graph.ExpandStrategy, + PainTagCount: graph.PainTagCount, + GeneratedAt: graph.GeneratedAt, + CreateAt: graph.CreateAt, + UpdateAt: graph.UpdateAt, } } diff --git a/haixun-backend/internal/logic/brand/list_brand_products_logic.go b/haixun-backend/internal/logic/brand/list_brand_products_logic.go new file mode 100644 index 0000000..3ecb8e3 --- /dev/null +++ b/haixun-backend/internal/logic/brand/list_brand_products_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package brand + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListBrandProductsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListBrandProductsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListBrandProductsLogic { + return &ListBrandProductsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *ListBrandProductsLogic) ListBrandProducts(req *types.BrandPath) (resp *types.ListBrandProductsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.Brand.ListProducts(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return toListBrandProductsData(result), nil +} diff --git a/haixun-backend/internal/logic/brand/list_brand_scan_posts_logic.go b/haixun-backend/internal/logic/brand/list_brand_scan_posts_logic.go index c358b9b..7773e38 100644 --- a/haixun-backend/internal/logic/brand/list_brand_scan_posts_logic.go +++ b/haixun-backend/internal/logic/brand/list_brand_scan_posts_logic.go @@ -57,28 +57,14 @@ func (l *ListBrandScanPostsLogic) ListBrandScanPosts(req *types.ListBrandScanPos list := make([]types.ScanPostData, 0, len(posts)) for _, post := range posts { - list = append(list, types.ScanPostData{ - ID: post.ID, - GraphNodeID: post.GraphNodeID, - SearchTag: post.SearchTag, - QueryDimension: post.QueryDimension, - ExternalID: post.ExternalID, - Permalink: post.Permalink, - Author: post.Author, - Text: post.Text, - Priority: post.Priority, - PlacementScore: post.PlacementScore, - ProductFitScore: post.ProductFitScore, - SolvedByProduct: post.SolvedByProduct, - Source: post.Source, - ScanJobID: post.ScanJobID, - OutreachStatus: post.OutreachStatus, - PublishedReplyID: post.PublishedReplyID, - PublishedPermalink: post.PublishedPermalink, - OutreachUpdateAt: post.OutreachUpdateAt, - Replies: toScanReplyData(post.Replies), - CreateAt: post.CreateAt, - }) + if mapped := toScanPostData(&post); mapped != nil { + if draft, draftErr := l.svcCtx.OutreachDraft.GetLatestByScanPost(l.ctx, tenantID, uid, req.ID, post.ID); draftErr != nil { + return nil, draftErr + } else if draft != nil { + mapped.LatestDraft = toOutreachDraftData(draft) + } + list = append(list, *mapped) + } } return &types.ListBrandScanPostsData{List: list, Total: len(list)}, nil } diff --git a/haixun-backend/internal/logic/brand/mapper.go b/haixun-backend/internal/logic/brand/mapper.go index 47fefad..5e6bf1e 100644 --- a/haixun-backend/internal/logic/brand/mapper.go +++ b/haixun-backend/internal/logic/brand/mapper.go @@ -7,13 +7,20 @@ import ( ) func toBrandData(item domusecase.BrandSummary) types.BrandData { + products := make([]types.BrandProductData, 0, len(item.Products)) + for _, product := range item.Products { + products = append(products, toBrandProductData(product)) + } return types.BrandData{ ID: item.ID, DisplayName: item.DisplayName, + TopicName: item.TopicName, SeedQuery: item.SeedQuery, Brief: item.Brief, ProductBrief: item.ProductBrief, ProductContext: item.ProductContext, + ProductID: item.ProductID, + Products: products, TargetAudience: item.TargetAudience, Goals: item.Goals, ResearchMap: toResearchMapData(item.ResearchMap), @@ -22,13 +29,47 @@ func toBrandData(item domusecase.BrandSummary) types.BrandData { } } +func toBrandProductData(item domusecase.ProductSummary) types.BrandProductData { + return types.BrandProductData{ + ID: item.ID, + Label: item.Label, + ProductContext: item.ProductContext, + MatchTags: append([]string(nil), item.MatchTags...), + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} + +func toListBrandProductsData(result *domusecase.ListProductsResult) *types.ListBrandProductsData { + if result == nil { + return &types.ListBrandProductsData{List: []types.BrandProductData{}} + } + list := make([]types.BrandProductData, 0, len(result.List)) + for _, item := range result.List { + list = append(list, toBrandProductData(item)) + } + return &types.ListBrandProductsData{List: list} +} + func toResearchMapData(item brandentity.ResearchMap) types.ResearchMapData { + items := make([]types.ResearchItemData, 0, len(item.ResearchItems)) + for _, researchItem := range item.ResearchItems { + items = append(items, types.ResearchItemData{ + Title: researchItem.Title, + URL: researchItem.URL, + Snippet: researchItem.Snippet, + Query: researchItem.Query, + }) + } return types.ResearchMapData{ AudienceSummary: item.AudienceSummary, ContentGoal: item.ContentGoal, Questions: item.Questions, Pillars: item.Pillars, Exclusions: item.Exclusions, + ResearchItems: items, + ExpandStrategy: item.ExpandStrategy, + PatrolKeywords: append([]string(nil), item.PatrolKeywords...), } } @@ -47,13 +88,38 @@ func toBrandPatch(req *types.UpdateBrandReq) domusecase.BrandPatch { if req == nil { return domusecase.BrandPatch{} } - return domusecase.BrandPatch{ + patch := domusecase.BrandPatch{ DisplayName: req.DisplayName, + TopicName: req.TopicName, SeedQuery: req.SeedQuery, Brief: req.Brief, ProductBrief: req.ProductBrief, ProductContext: req.ProductContext, + ProductID: req.ProductID, TargetAudience: req.TargetAudience, Goals: req.Goals, } + if req.AudienceSummary != nil { + patch.AudienceSummary = req.AudienceSummary + } + if req.ContentGoal != nil { + patch.ContentGoal = req.ContentGoal + } + if req.Questions != nil { + patch.QuestionsSet = true + patch.Questions = append([]string(nil), req.Questions...) + } + if req.Pillars != nil { + patch.PillarsSet = true + patch.Pillars = append([]string(nil), req.Pillars...) + } + if req.Exclusions != nil { + patch.ExclusionsSet = true + patch.Exclusions = append([]string(nil), req.Exclusions...) + } + if req.PatrolKeywords != nil { + patch.PatrolKeywordsSet = true + patch.PatrolKeywords = append([]string(nil), req.PatrolKeywords...) + } + return patch } diff --git a/haixun-backend/internal/logic/brand/patch_knowledge_graph_nodes_logic.go b/haixun-backend/internal/logic/brand/patch_knowledge_graph_nodes_logic.go index 5a473a3..c7aba8e 100644 --- a/haixun-backend/internal/logic/brand/patch_knowledge_graph_nodes_logic.go +++ b/haixun-backend/internal/logic/brand/patch_knowledge_graph_nodes_logic.go @@ -42,14 +42,34 @@ func (l *PatchKnowledgeGraphNodesLogic) PatchKnowledgeGraphNodes(req *types.Patc return nil, err } - updates := make([]kgusecase.NodeSelectionUpdate, 0, len(req.Updates)) + updates := make([]kgusecase.NodeUpdate, 0, len(req.Updates)) for _, item := range req.Updates { - updates = append(updates, kgusecase.NodeSelectionUpdate{ - NodeID: strings.TrimSpace(item.NodeID), - SelectedForScan: item.SelectedForScan, - }) + 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.Brand).InputMissingRequired("each update needs selected_for_scan or patrol tags") + } + updates = append(updates, update) } - graph, err := l.svcCtx.KnowledgeGraph.UpdateNodeSelections(l.ctx, kgusecase.UpdateNodesRequest{ + if len(updates) == 0 { + return nil, app.For(code.Brand).InputMissingRequired("updates is required") + } + + graph, err := l.svcCtx.KnowledgeGraph.UpdateNodes(l.ctx, kgusecase.UpdateNodesRequest{ TenantID: tenantID, OwnerUID: uid, BrandID: req.ID, diff --git a/haixun-backend/internal/logic/brand/patch_scan_post_outreach_logic.go b/haixun-backend/internal/logic/brand/patch_scan_post_outreach_logic.go index 171ba99..34cb05d 100644 --- a/haixun-backend/internal/logic/brand/patch_scan_post_outreach_logic.go +++ b/haixun-backend/internal/logic/brand/patch_scan_post_outreach_logic.go @@ -87,6 +87,7 @@ func toScanPostData(post *scanpostusecase.ScanPostSummary) *types.ScanPostData { PublishedReplyID: post.PublishedReplyID, PublishedPermalink: post.PublishedPermalink, OutreachUpdateAt: post.OutreachUpdateAt, + PostedAt: post.PostedAt, Replies: toScanReplyData(post.Replies), CreateAt: post.CreateAt, } diff --git a/haixun-backend/internal/logic/brand/start_brand_scan_job_logic.go b/haixun-backend/internal/logic/brand/start_brand_scan_job_logic.go index 044b5ec..2ae86c6 100644 --- a/haixun-backend/internal/logic/brand/start_brand_scan_job_logic.go +++ b/haixun-backend/internal/logic/brand/start_brand_scan_job_logic.go @@ -9,13 +9,26 @@ import ( 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" + kgdom "haixun-backend/internal/model/knowledge_graph/domain/usecase" "haixun-backend/internal/svc" "haixun-backend/internal/types" "github.com/zeromicro/go-zero/core/logx" ) +func IsKnowledgeGraphNotFound(err error) bool { + if err == nil { + return false + } + if e := app.FromError(err); e != nil && e.Category() == code.ResNotFound { + return true + } + return strings.Contains(strings.ToLower(err.Error()), "knowledge graph not found") +} + type StartBrandScanJobLogic struct { logx.Logger ctx context.Context @@ -35,34 +48,59 @@ func (l *StartBrandScanJobLogic) StartBrandScanJob(req *types.StartBrandScanJobH if err != nil { return nil, err } - if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil { - return nil, err - } - graph, err := l.svcCtx.KnowledgeGraph.Get(l.ctx, tenantID, uid, req.ID) + brand, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID) if err != nil { return nil, err } + var graph *kgdom.GraphSummary + if loaded, err := l.svcCtx.KnowledgeGraph.Get(l.ctx, tenantID, uid, req.ID); err != nil { + if !IsKnowledgeGraphNotFound(err) { + return nil, err + } + } else { + graph = loaded + } selected := 0 - for _, node := range graph.Nodes { - if node.SelectedForScan { - selected++ + if graph != nil { + for _, node := range graph.Nodes { + if node.SelectedForScan { + selected++ + } } } nodeIDs := []string{} - graphID := graph.ID for _, id := range req.NodeIDs { id = strings.TrimSpace(id) if id != "" { nodeIDs = append(nodeIDs, id) } } + graphID := req.ID + if graph != nil && strings.TrimSpace(graph.ID) != "" { + graphID = graph.ID + } if strings.TrimSpace(req.GraphID) != "" { graphID = strings.TrimSpace(req.GraphID) } dualTrack := true - if len(nodeIDs) == 0 && selected == 0 { - return nil, app.For(code.Brand).InputMissingRequired("請先在研究頁勾選至少一個節點") + patrolMode := req.PatrolMode + patrolKeywords := libkg.NormalizePatrolKeywordList(brand.ResearchMap.PatrolKeywords) + if len(patrolKeywords) == 0 && graph != nil { + productBrief := strings.TrimSpace(brand.ProductBrief) + if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" { + productBrief = formatted + } + patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief) + patrolKeywords = libkg.NormalizePatrolKeywordList(libkg.CollectPatrolTagsFromGraph(patrolInput, graph.Nodes)) + } + if patrolMode || (len(nodeIDs) == 0 && selected == 0) { + if len(patrolKeywords) == 0 { + return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字") + } + patrolMode = true + } else if graph == nil { + return nil, app.For(code.Brand).ResNotFound("請先產生延伸知識圖譜,或改用手動海巡關鍵字") } research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) @@ -76,12 +114,15 @@ func (l *StartBrandScanJobLogic) StartBrandScanJob(req *types.StartBrandScanJobH if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { return nil, app.For(code.Setting).InputMissingRequired("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session") } - if memberCtx.AllowsBrave && strings.TrimSpace(research.BraveAPIKey) == "" { + if placement.MemberNeedsBraveKey(memberCtx) && strings.TrimSpace(research.BraveAPIKey) == "" { return nil, app.For(code.Setting).InputMissingRequired("請在設定頁設定 Brave Search API key(跟隨此登入帳號)") } if memberCtx.DevMode && !memberCtx.BrowserConnected { return nil, app.For(code.Setting).InputMissingRequired("開發模式需先同步 Chrome Session") } + if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { + return nil, app.For(code.Setting).InputMissingRequired("正式模式需先完成 Threads API 連線") + } payload := map[string]any{ "brand_id": req.ID, @@ -89,6 +130,10 @@ func (l *StartBrandScanJobLogic) StartBrandScanJob(req *types.StartBrandScanJobH "dual_track": dualTrack, "node_ids": nodeIDs, } + if patrolMode { + payload["patrol_mode"] = true + payload["patrol_keywords"] = patrolKeywords + } for key, value := range memberCtx.PayloadFields() { payload[key] = value } diff --git a/haixun-backend/internal/logic/brand/update_brand_product_logic.go b/haixun-backend/internal/logic/brand/update_brand_product_logic.go new file mode 100644 index 0000000..34e8b5d --- /dev/null +++ b/haixun-backend/internal/logic/brand/update_brand_product_logic.go @@ -0,0 +1,50 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package brand + +import ( + "context" + + domusecase "haixun-backend/internal/model/brand/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateBrandProductLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateBrandProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateBrandProductLogic { + return &UpdateBrandProductLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *UpdateBrandProductLogic) UpdateBrandProduct(req *types.UpdateBrandProductHandlerReq) (resp *types.BrandProductData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.Brand.UpdateProduct(l.ctx, domusecase.UpdateProductRequest{ + TenantID: tenantID, + OwnerUID: uid, + BrandID: req.ID, + ProductID: req.ProductID, + Label: req.Label, + ProductContext: req.ProductContext, + MatchTags: req.MatchTags, + MatchTagsSet: req.MatchTags != nil, + }) + if err != nil { + return nil, err + } + out := toBrandProductData(*item) + return &out, nil +} diff --git a/haixun-backend/internal/logic/member/mapper.go b/haixun-backend/internal/logic/member/mapper.go index 7f0168b..229f7e8 100644 --- a/haixun-backend/internal/logic/member/mapper.go +++ b/haixun-backend/internal/logic/member/mapper.go @@ -28,6 +28,7 @@ func toPlacementSettingsData(settings *placementusecase.Settings) types.MemberPl BraveAPIKeyConfigured: settings.BraveAPIKeyConfigured, BraveCountry: settings.BraveCountry, BraveSearchLang: settings.BraveSearchLang, + ExpandStrategy: settings.ExpandStrategy, } } diff --git a/haixun-backend/internal/logic/member/update_member_placement_settings_logic.go b/haixun-backend/internal/logic/member/update_member_placement_settings_logic.go index ef2faa7..92b9a1f 100644 --- a/haixun-backend/internal/logic/member/update_member_placement_settings_logic.go +++ b/haixun-backend/internal/logic/member/update_member_placement_settings_logic.go @@ -36,6 +36,7 @@ func (l *UpdateMemberPlacementSettingsLogic) UpdateMemberPlacementSettings(req * BraveAPIKey: req.BraveAPIKey, BraveCountry: req.BraveCountry, BraveSearchLang: req.BraveSearchLang, + ExpandStrategy: req.ExpandStrategy, }) if err != nil { return nil, err diff --git a/haixun-backend/internal/logic/placement_topic/actor.go b/haixun-backend/internal/logic/placement_topic/actor.go new file mode 100644 index 0000000..d8db619 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/actor.go @@ -0,0 +1,17 @@ +package placement_topic + +import ( + "context" + + "haixun-backend/internal/library/authctx" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" +) + +func actorFrom(ctx context.Context) (tenantID, uid string, err error) { + actor, ok := authctx.ActorFromContext(ctx) + if !ok { + return "", "", app.For(code.Auth).AuthUnauthorized("missing actor") + } + return actor.TenantID, actor.UID, nil +} diff --git a/haixun-backend/internal/logic/placement_topic/batch_delete_placement_topic_scan_posts_logic.go b/haixun-backend/internal/logic/placement_topic/batch_delete_placement_topic_scan_posts_logic.go new file mode 100644 index 0000000..be483de --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/batch_delete_placement_topic_scan_posts_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type BatchDeletePlacementTopicScanPostsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewBatchDeletePlacementTopicScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BatchDeletePlacementTopicScanPostsLogic { + return &BatchDeletePlacementTopicScanPostsLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *BatchDeletePlacementTopicScanPostsLogic) BatchDeletePlacementTopicScanPosts(req *types.BatchDeletePlacementTopicScanPostsHandlerReq) (resp *types.BatchDeletePlacementTopicScanPostsData, err error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + count, err := l.svcCtx.ScanPost.DeleteMany(l.ctx, tenantID, uid, scope.BrandID, scope.TopicID, req.PostIDs) + if err != nil { + return nil, err + } + return &types.BatchDeletePlacementTopicScanPostsData{DeletedCount: count}, nil +} diff --git a/haixun-backend/internal/logic/placement_topic/create_placement_topic_logic.go b/haixun-backend/internal/logic/placement_topic/create_placement_topic_logic.go new file mode 100644 index 0000000..2c0eed6 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/create_placement_topic_logic.go @@ -0,0 +1,43 @@ +package placement_topic + +import ( + "context" + "strings" + + topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreatePlacementTopicLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreatePlacementTopicLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreatePlacementTopicLogic { + return &CreatePlacementTopicLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *CreatePlacementTopicLogic) CreatePlacementTopic(req *types.CreatePlacementTopicHandlerReq) (*types.PlacementTopicData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PlacementTopic.Create(l.ctx, topicdomain.CreateRequest{ + TenantID: tenantID, + OwnerUID: uid, + BrandID: strings.TrimSpace(req.BrandID), + TopicName: strings.TrimSpace(req.TopicName), + SeedQuery: strings.TrimSpace(req.SeedQuery), + Brief: strings.TrimSpace(req.Brief), + ProductID: strings.TrimSpace(req.ProductID), + }) + if err != nil { + return nil, err + } + data := toPlacementTopicData(*item) + return &data, nil +} diff --git a/haixun-backend/internal/logic/placement_topic/delete_placement_topic_logic.go b/haixun-backend/internal/logic/placement_topic/delete_placement_topic_logic.go new file mode 100644 index 0000000..77deed9 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/delete_placement_topic_logic.go @@ -0,0 +1,28 @@ +package placement_topic + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeletePlacementTopicLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeletePlacementTopicLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePlacementTopicLogic { + return &DeletePlacementTopicLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *DeletePlacementTopicLogic) DeletePlacementTopic(req *types.PlacementTopicPath) error { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return err + } + return l.svcCtx.PlacementTopic.Delete(l.ctx, tenantID, uid, req.ID) +} diff --git a/haixun-backend/internal/logic/placement_topic/delete_placement_topic_scan_post_logic.go b/haixun-backend/internal/logic/placement_topic/delete_placement_topic_scan_post_logic.go new file mode 100644 index 0000000..e86128e --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/delete_placement_topic_scan_post_logic.go @@ -0,0 +1,39 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package placement_topic + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DeletePlacementTopicScanPostLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDeletePlacementTopicScanPostLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePlacementTopicScanPostLogic { + return &DeletePlacementTopicScanPostLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *DeletePlacementTopicScanPostLogic) DeletePlacementTopicScanPost(req *types.DeletePlacementTopicScanPostHandlerReq) error { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return err + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return err + } + return l.svcCtx.ScanPost.Delete(l.ctx, tenantID, uid, scope.BrandID, scope.TopicID, req.PostID) +} diff --git a/haixun-backend/internal/logic/placement_topic/expand_placement_topic_graph_logic.go b/haixun-backend/internal/logic/placement_topic/expand_placement_topic_graph_logic.go new file mode 100644 index 0000000..255e9d3 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/expand_placement_topic_graph_logic.go @@ -0,0 +1,83 @@ +package placement_topic + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libkg "haixun-backend/internal/library/knowledge" + jobdom "haixun-backend/internal/model/job/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ExpandPlacementTopicGraphLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewExpandPlacementTopicGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ExpandPlacementTopicGraphLogic { + return &ExpandPlacementTopicGraphLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ExpandPlacementTopicGraphLogic) ExpandPlacementTopicGraph(req *types.ExpandPlacementTopicGraphHandlerReq) (*types.ExpandKnowledgeGraphData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + seed := strings.TrimSpace(req.SeedQuery) + if seed == "" { + return nil, app.For(code.Brand).InputMissingRequired("seed_query is required") + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + + research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + expandStrategy := libkg.ParseExpandStrategy(research.ExpandStrategy) + if req.Supplemental { + expandStrategy = libkg.ExpandStrategyBrave + } + memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) + if err != nil { + return nil, err + } + if expandStrategy.RequiresBrave() && strings.TrimSpace(research.BraveAPIKey) == "" { + return nil, app.For(code.Setting).InputMissingRequired("請到設定頁完成研究資料連線") + } + + payload := map[string]any{ + "topic_id": scope.TopicID, + "brand_id": scope.BrandID, + "seed_query": seed, + "supplemental": req.Supplemental, + "regenerate_map": req.RegenerateMap, + "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-graph", + Scope: "placement_topic", + ScopeID: scope.TopicID, + Payload: payload, + }) + if err != nil { + return nil, err + } + return &types.ExpandKnowledgeGraphData{ + JobID: run.ID.Hex(), + Status: string(run.Status), + Message: "研究地圖產生中,完成後可檢視延伸知識與參考連結", + }, nil +} diff --git a/haixun-backend/internal/logic/placement_topic/generate_placement_topic_content_matrix_logic.go b/haixun-backend/internal/logic/placement_topic/generate_placement_topic_content_matrix_logic.go new file mode 100644 index 0000000..ed1b4d3 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/generate_placement_topic_content_matrix_logic.go @@ -0,0 +1,33 @@ +package placement_topic + +import ( + "context" + + brandlogic "haixun-backend/internal/logic/brand" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GeneratePlacementTopicContentMatrixLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGeneratePlacementTopicContentMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GeneratePlacementTopicContentMatrixLogic { + return &GeneratePlacementTopicContentMatrixLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GeneratePlacementTopicContentMatrixLogic) GeneratePlacementTopicContentMatrix(req *types.GeneratePlacementTopicContentMatrixHandlerReq) (*types.ContentMatrixData, error) { + if _, _, err := actorFrom(l.ctx); err != nil { + return nil, err + } + return brandlogic.NewGenerateBrandContentMatrixLogic(l.ctx, l.svcCtx).GenerateBrandContentMatrix(&types.GenerateContentMatrixHandlerReq{ + BrandPath: types.BrandPath{ID: req.ID}, + GenerateContentMatrixReq: types.GenerateContentMatrixReq{ + Count: req.Count, + }, + }) +} diff --git a/haixun-backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go b/haixun-backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go new file mode 100644 index 0000000..1956650 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go @@ -0,0 +1,41 @@ +package placement_topic + +import ( + "context" + + brandlogic "haixun-backend/internal/logic/brand" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GeneratePlacementTopicOutreachDraftsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGeneratePlacementTopicOutreachDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GeneratePlacementTopicOutreachDraftsLogic { + return &GeneratePlacementTopicOutreachDraftsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GeneratePlacementTopicOutreachDraftsLogic) GeneratePlacementTopicOutreachDrafts(req *types.GeneratePlacementTopicOutreachDraftsHandlerReq) (*types.GenerateOutreachDraftsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return brandlogic.NewGenerateOutreachDraftsLogic(l.ctx, l.svcCtx).GenerateOutreachDrafts(&types.GenerateOutreachDraftsHandlerReq{ + BrandPath: types.BrandPath{ID: scope.BrandID}, + GenerateOutreachDraftsReq: types.GenerateOutreachDraftsReq{ + ScanPostID: req.ScanPostID, + Count: req.Count, + VoicePersonaID: req.VoicePersonaID, + ProductID: scope.Topic.ProductID, + }, + }) +} diff --git a/haixun-backend/internal/logic/placement_topic/get_placement_topic_content_matrix_logic.go b/haixun-backend/internal/logic/placement_topic/get_placement_topic_content_matrix_logic.go new file mode 100644 index 0000000..564d9d9 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/get_placement_topic_content_matrix_logic.go @@ -0,0 +1,28 @@ +package placement_topic + +import ( + "context" + + brandlogic "haixun-backend/internal/logic/brand" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPlacementTopicContentMatrixLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPlacementTopicContentMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlacementTopicContentMatrixLogic { + return &GetPlacementTopicContentMatrixLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetPlacementTopicContentMatrixLogic) GetPlacementTopicContentMatrix(req *types.PlacementTopicPath) (*types.ContentMatrixData, error) { + if _, _, err := actorFrom(l.ctx); err != nil { + return nil, err + } + return brandlogic.NewGetBrandContentMatrixLogic(l.ctx, l.svcCtx).GetBrandContentMatrix(&types.BrandPath{ID: req.ID}) +} diff --git a/haixun-backend/internal/logic/placement_topic/get_placement_topic_graph_logic.go b/haixun-backend/internal/logic/placement_topic/get_placement_topic_graph_logic.go new file mode 100644 index 0000000..42a28fc --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/get_placement_topic_graph_logic.go @@ -0,0 +1,36 @@ +package placement_topic + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPlacementTopicGraphLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPlacementTopicGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlacementTopicGraphLogic { + return &GetPlacementTopicGraphLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetPlacementTopicGraphLogic) GetPlacementTopicGraph(req *types.PlacementTopicPath) (*types.KnowledgeGraphData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + if _, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID); err != nil { + return nil, err + } + graph, err := l.svcCtx.KnowledgeGraph.GetByTopic(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + data := toKnowledgeGraphData(graph) + return &data, nil +} diff --git a/haixun-backend/internal/logic/placement_topic/get_placement_topic_logic.go b/haixun-backend/internal/logic/placement_topic/get_placement_topic_logic.go new file mode 100644 index 0000000..8fc4f60 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/get_placement_topic_logic.go @@ -0,0 +1,33 @@ +package placement_topic + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPlacementTopicLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPlacementTopicLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlacementTopicLogic { + return &GetPlacementTopicLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetPlacementTopicLogic) GetPlacementTopic(req *types.PlacementTopicPath) (*types.PlacementTopicData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PlacementTopic.Get(l.ctx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + data := toPlacementTopicData(*item) + return &data, nil +} diff --git a/haixun-backend/internal/logic/placement_topic/get_placement_topic_scan_schedule_logic.go b/haixun-backend/internal/logic/placement_topic/get_placement_topic_scan_schedule_logic.go new file mode 100644 index 0000000..c4f05a9 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/get_placement_topic_scan_schedule_logic.go @@ -0,0 +1,28 @@ +package placement_topic + +import ( + "context" + + brandlogic "haixun-backend/internal/logic/brand" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPlacementTopicScanScheduleLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPlacementTopicScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlacementTopicScanScheduleLogic { + return &GetPlacementTopicScanScheduleLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetPlacementTopicScanScheduleLogic) GetPlacementTopicScanSchedule(req *types.PlacementTopicPath) (*types.BrandScanScheduleData, error) { + if _, _, err := actorFrom(l.ctx); err != nil { + return nil, err + } + return brandlogic.NewGetBrandScanScheduleLogic(l.ctx, l.svcCtx).GetBrandScanSchedule(&types.BrandPath{ID: req.ID}) +} diff --git a/haixun-backend/internal/logic/placement_topic/list_placement_topic_scan_posts_logic.go b/haixun-backend/internal/logic/placement_topic/list_placement_topic_scan_posts_logic.go new file mode 100644 index 0000000..0a68a11 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/list_placement_topic_scan_posts_logic.go @@ -0,0 +1,62 @@ +package placement_topic + +import ( + "context" + "strings" + + scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPlacementTopicScanPostsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPlacementTopicScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPlacementTopicScanPostsLogic { + return &ListPlacementTopicScanPostsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListPlacementTopicScanPostsLogic) ListPlacementTopicScanPosts(req *types.ListPlacementTopicScanPostsHandlerReq) (*types.ListBrandScanPostsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + listReq := scanpostusecase.ListRequest{ + TenantID: tenantID, + OwnerUID: uid, + BrandID: scope.BrandID, + TopicID: scope.TopicID, + Limit: 100, + } + listReq.Priority = strings.TrimSpace(req.Priority) + listReq.Recent7dOnly = req.Recent7d + listReq.ProductFitMin = req.ProductFitMin + if req.Limit > 0 { + listReq.Limit = req.Limit + } + posts, err := l.svcCtx.ScanPost.List(l.ctx, listReq) + if err != nil { + return nil, err + } + list := make([]types.ScanPostData, 0, len(posts)) + for _, post := range posts { + if mapped := toScanPostData(&post); mapped != nil { + if draft, draftErr := l.svcCtx.OutreachDraft.GetLatestByScanPost(l.ctx, tenantID, uid, scope.BrandID, post.ID); draftErr != nil { + return nil, draftErr + } else if draft != nil { + mapped.LatestDraft = toOutreachDraftData(draft) + } + list = append(list, *mapped) + } + } + return &types.ListBrandScanPostsData{List: list, Total: len(list)}, nil +} diff --git a/haixun-backend/internal/logic/placement_topic/list_placement_topics_logic.go b/haixun-backend/internal/logic/placement_topic/list_placement_topics_logic.go new file mode 100644 index 0000000..40c0977 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/list_placement_topics_logic.go @@ -0,0 +1,32 @@ +package placement_topic + +import ( + "context" + + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListPlacementTopicsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListPlacementTopicsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPlacementTopicsLogic { + return &ListPlacementTopicsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListPlacementTopicsLogic) ListPlacementTopics() (*types.ListPlacementTopicsData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + result, err := l.svcCtx.PlacementTopic.List(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + return toListData(result), nil +} diff --git a/haixun-backend/internal/logic/placement_topic/mapper.go b/haixun-backend/internal/logic/placement_topic/mapper.go new file mode 100644 index 0000000..a25ae52 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/mapper.go @@ -0,0 +1,224 @@ +package placement_topic + +import ( + brandentity "haixun-backend/internal/model/brand/domain/entity" + kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" + outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase" + domusecase "haixun-backend/internal/model/placement_topic/domain/usecase" + scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase" + "haixun-backend/internal/types" +) + +func toPlacementTopicData(item domusecase.TopicSummary) types.PlacementTopicData { + return types.PlacementTopicData{ + ID: item.ID, + BrandID: item.BrandID, + BrandDisplayName: item.BrandDisplayName, + TopicName: item.TopicName, + SeedQuery: item.SeedQuery, + Brief: item.Brief, + ProductID: item.ProductID, + ResearchMap: toResearchMapData(item.ResearchMap), + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} + +func toListData(result *domusecase.ListResult) *types.ListPlacementTopicsData { + if result == nil { + return &types.ListPlacementTopicsData{List: []types.PlacementTopicData{}} + } + list := make([]types.PlacementTopicData, 0, len(result.List)) + for _, item := range result.List { + list = append(list, toPlacementTopicData(item)) + } + return &types.ListPlacementTopicsData{List: list} +} + +func toResearchMapData(item brandentity.ResearchMap) types.ResearchMapData { + items := make([]types.ResearchItemData, 0, len(item.ResearchItems)) + for _, researchItem := range item.ResearchItems { + items = append(items, types.ResearchItemData{ + Title: researchItem.Title, + URL: researchItem.URL, + Snippet: researchItem.Snippet, + Query: researchItem.Query, + }) + } + return types.ResearchMapData{ + AudienceSummary: item.AudienceSummary, + ContentGoal: item.ContentGoal, + Questions: item.Questions, + Pillars: item.Pillars, + Exclusions: item.Exclusions, + ResearchItems: items, + ExpandStrategy: item.ExpandStrategy, + PatrolKeywords: append([]string(nil), item.PatrolKeywords...), + } +} + +func toTopicPatch(req *types.UpdatePlacementTopicReq) domusecase.TopicPatch { + if req == nil { + return domusecase.TopicPatch{} + } + patch := domusecase.TopicPatch{ + BrandID: req.BrandID, + TopicName: req.TopicName, + SeedQuery: req.SeedQuery, + Brief: req.Brief, + ProductID: req.ProductID, + } + if req.AudienceSummary != nil { + patch.AudienceSummary = req.AudienceSummary + } + if req.ContentGoal != nil { + patch.ContentGoal = req.ContentGoal + } + if req.Questions != nil { + patch.QuestionsSet = true + patch.Questions = append([]string(nil), req.Questions...) + } + if req.Pillars != nil { + patch.PillarsSet = true + patch.Pillars = append([]string(nil), req.Pillars...) + } + if req.Exclusions != nil { + patch.ExclusionsSet = true + patch.Exclusions = append([]string(nil), req.Exclusions...) + } + if req.PatrolKeywords != nil { + patch.PatrolKeywordsSet = true + patch.PatrolKeywords = append([]string(nil), req.PatrolKeywords...) + } + return patch +} + +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, + } +} + +func toScanPostData(post *scanpostusecase.ScanPostSummary) *types.ScanPostData { + if post == nil { + return nil + } + return &types.ScanPostData{ + ID: post.ID, + GraphNodeID: post.GraphNodeID, + SearchTag: post.SearchTag, + QueryDimension: post.QueryDimension, + ExternalID: post.ExternalID, + Permalink: post.Permalink, + Author: post.Author, + Text: post.Text, + Priority: post.Priority, + PlacementScore: post.PlacementScore, + ProductFitScore: post.ProductFitScore, + SolvedByProduct: post.SolvedByProduct, + Source: post.Source, + ScanJobID: post.ScanJobID, + OutreachStatus: post.OutreachStatus, + PublishedReplyID: post.PublishedReplyID, + PublishedPermalink: post.PublishedPermalink, + OutreachUpdateAt: post.OutreachUpdateAt, + PostedAt: post.PostedAt, + Replies: toScanReplyData(post.Replies), + CreateAt: post.CreateAt, + } +} + +func toScanReplyData(replies []scanpostusecase.ScanReplySummary) []types.ScanReplyData { + if len(replies) == 0 { + return nil + } + out := make([]types.ScanReplyData, 0, len(replies)) + for _, reply := range replies { + out = append(out, types.ScanReplyData{ + ExternalID: reply.ExternalID, + Author: reply.Author, + Text: reply.Text, + Permalink: reply.Permalink, + LikeCount: reply.LikeCount, + PostedAt: reply.PostedAt, + }) + } + return out +} + +func toOutreachDraftData(saved *outreachusecase.DraftSummary) *types.GenerateOutreachDraftsData { + if saved == nil { + return nil + } + drafts := make([]types.OutreachDraftItemData, 0, len(saved.Drafts)) + for _, item := range saved.Drafts { + drafts = append(drafts, types.OutreachDraftItemData{ + Text: item.Text, + Angle: item.Angle, + Rationale: item.Rationale, + }) + } + return &types.GenerateOutreachDraftsData{ + ID: saved.ID, + ScanPostID: saved.ScanPostID, + Relevance: saved.Relevance, + Reason: saved.Reason, + Drafts: drafts, + CreateAt: saved.CreateAt, + } +} diff --git a/haixun-backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go b/haixun-backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go new file mode 100644 index 0000000..3c5c85a --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go @@ -0,0 +1,74 @@ +package placement_topic + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + 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 PatchPlacementTopicGraphNodesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPatchPlacementTopicGraphNodesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchPlacementTopicGraphNodesLogic { + return &PatchPlacementTopicGraphNodesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *PatchPlacementTopicGraphNodesLogic) PatchPlacementTopicGraphNodes(req *types.PatchPlacementTopicGraphNodesHandlerReq) (*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.Brand).InputMissingRequired("updates is required") + } + if _, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID); 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.Brand).InputMissingRequired("each update needs selected_for_scan or patrol tags") + } + updates = append(updates, update) + } + if len(updates) == 0 { + return nil, app.For(code.Brand).InputMissingRequired("updates is required") + } + graph, err := l.svcCtx.KnowledgeGraph.UpdateNodes(l.ctx, kgusecase.UpdateNodesRequest{ + TenantID: tenantID, + OwnerUID: uid, + TopicID: req.ID, + Updates: updates, + }) + if err != nil { + return nil, err + } + data := toKnowledgeGraphData(graph) + return &data, nil +} diff --git a/haixun-backend/internal/logic/placement_topic/patch_placement_topic_scan_post_outreach_logic.go b/haixun-backend/internal/logic/placement_topic/patch_placement_topic_scan_post_outreach_logic.go new file mode 100644 index 0000000..fe67aa6 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/patch_placement_topic_scan_post_outreach_logic.go @@ -0,0 +1,39 @@ +package placement_topic + +import ( + "context" + + brandlogic "haixun-backend/internal/logic/brand" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PatchPlacementTopicScanPostOutreachLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPatchPlacementTopicScanPostOutreachLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchPlacementTopicScanPostOutreachLogic { + return &PatchPlacementTopicScanPostOutreachLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *PatchPlacementTopicScanPostOutreachLogic) PatchPlacementTopicScanPostOutreach(req *types.PatchPlacementTopicScanPostOutreachHandlerReq) (*types.ScanPostData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return brandlogic.NewPatchScanPostOutreachLogic(l.ctx, l.svcCtx).PatchScanPostOutreach(&types.PatchScanPostOutreachHandlerReq{ + BrandPath: types.BrandPath{ID: scope.BrandID}, + PostID: req.PostID, + PatchScanPostOutreachReq: types.PatchScanPostOutreachReq{ + OutreachStatus: req.OutreachStatus, + }, + }) +} diff --git a/haixun-backend/internal/logic/placement_topic/publish_placement_topic_outreach_draft_logic.go b/haixun-backend/internal/logic/placement_topic/publish_placement_topic_outreach_draft_logic.go new file mode 100644 index 0000000..bbf45e9 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/publish_placement_topic_outreach_draft_logic.go @@ -0,0 +1,40 @@ +package placement_topic + +import ( + "context" + + brandlogic "haixun-backend/internal/logic/brand" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PublishPlacementTopicOutreachDraftLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPublishPlacementTopicOutreachDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishPlacementTopicOutreachDraftLogic { + return &PublishPlacementTopicOutreachDraftLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *PublishPlacementTopicOutreachDraftLogic) PublishPlacementTopicOutreachDraft(req *types.PublishPlacementTopicOutreachDraftHandlerReq) (*types.PublishOutreachDraftData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + return brandlogic.NewPublishOutreachDraftLogic(l.ctx, l.svcCtx).PublishOutreachDraft(&types.PublishOutreachDraftHandlerReq{ + BrandPath: types.BrandPath{ID: scope.BrandID}, + PublishOutreachDraftReq: types.PublishOutreachDraftReq{ + ScanPostID: req.ScanPostID, + Text: req.Text, + Confirm: req.Confirm, + }, + }) +} diff --git a/haixun-backend/internal/logic/placement_topic/scope.go b/haixun-backend/internal/logic/placement_topic/scope.go new file mode 100644 index 0000000..d5e82de --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/scope.go @@ -0,0 +1,33 @@ +package placement_topic + +import ( + "context" + + branddomain "haixun-backend/internal/model/brand/domain/usecase" + topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase" + "haixun-backend/internal/svc" +) + +type WorkflowScope struct { + TopicID string + BrandID string + Topic topicdomain.TopicSummary + Brand branddomain.BrandSummary +} + +func resolveScope(ctx context.Context, svcCtx *svc.ServiceContext, tenantID, uid, topicID string) (*WorkflowScope, error) { + topic, err := svcCtx.PlacementTopic.Get(ctx, tenantID, uid, topicID) + if err != nil { + return nil, err + } + brand, err := svcCtx.Brand.Get(ctx, tenantID, uid, topic.BrandID) + if err != nil { + return nil, err + } + return &WorkflowScope{ + TopicID: topic.ID, + BrandID: topic.BrandID, + Topic: *topic, + Brand: *brand, + }, nil +} diff --git a/haixun-backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go b/haixun-backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go new file mode 100644 index 0000000..a648432 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go @@ -0,0 +1,139 @@ +package placement_topic + +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" + brandlogic "haixun-backend/internal/logic/brand" + jobdom "haixun-backend/internal/model/job/domain/usecase" + kgdom "haixun-backend/internal/model/knowledge_graph/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type StartPlacementTopicScanJobLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPlacementTopicScanJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPlacementTopicScanJobLogic { + return &StartPlacementTopicScanJobLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.StartPlacementTopicScanJobHandlerReq) (*types.StartBrandScanJobData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID) + if err != nil { + return nil, err + } + var graph *kgdom.GraphSummary + if loaded, err := l.svcCtx.KnowledgeGraph.GetByTopic(l.ctx, tenantID, uid, scope.TopicID); err != nil { + if !brandlogic.IsKnowledgeGraphNotFound(err) { + return nil, err + } + } else { + graph = loaded + } + selected := 0 + if graph != nil { + for _, node := range graph.Nodes { + if node.SelectedForScan { + selected++ + } + } + } + nodeIDs := []string{} + for _, id := range req.NodeIDs { + id = strings.TrimSpace(id) + if id != "" { + nodeIDs = append(nodeIDs, id) + } + } + graphID := scope.TopicID + if graph != nil && strings.TrimSpace(graph.ID) != "" { + graphID = graph.ID + } + if strings.TrimSpace(req.GraphID) != "" { + graphID = strings.TrimSpace(req.GraphID) + } + dualTrack := true + patrolMode := req.PatrolMode + patrolKeywords := libkg.NormalizePatrolKeywordList(scope.Topic.ResearchMap.PatrolKeywords) + if len(patrolKeywords) == 0 && graph != nil { + brandForPatrol := scope.Brand + brandForPatrol.ProductID = scope.Topic.ProductID + brandForPatrol.ResearchMap = scope.Topic.ResearchMap + productBrief := strings.TrimSpace(brandForPatrol.ProductBrief) + if formatted := placement.ProductBriefFromContext(brandForPatrol.ProductContext); formatted != "" { + productBrief = formatted + } + patrolInput := libkg.PatrolTagInputFromBrand(&brandForPatrol, productBrief) + patrolKeywords = libkg.NormalizePatrolKeywordList(libkg.CollectPatrolTagsFromGraph(patrolInput, graph.Nodes)) + } + if patrolMode || (len(nodeIDs) == 0 && selected == 0) { + if len(patrolKeywords) == 0 { + return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字") + } + patrolMode = true + } else if graph == nil { + return nil, app.For(code.Brand).ResNotFound("請先產生延伸知識圖譜,或改用手動海巡關鍵字") + } + research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid) + if err != nil { + return nil, err + } + memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research) + if err != nil { + return nil, err + } + if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { + return nil, app.For(code.Setting).InputMissingRequired("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session") + } + if placement.MemberNeedsBraveKey(memberCtx) && strings.TrimSpace(research.BraveAPIKey) == "" { + return nil, app.For(code.Setting).InputMissingRequired("請在設定頁設定 Brave Search API key(跟隨此登入帳號)") + } + if memberCtx.DevMode && !memberCtx.BrowserConnected { + return nil, app.For(code.Setting).InputMissingRequired("開發模式需先同步 Chrome Session") + } + if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { + return nil, app.For(code.Setting).InputMissingRequired("正式模式需先完成 Threads API 連線") + } + payload := map[string]any{ + "topic_id": scope.TopicID, + "brand_id": scope.BrandID, + "graph_id": graphID, + "dual_track": dualTrack, + "node_ids": nodeIDs, + } + if patrolMode { + payload["patrol_mode"] = true + payload["patrol_keywords"] = patrolKeywords + } + for key, value := range memberCtx.PayloadFields() { + payload[key] = value + } + run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{ + TemplateType: "placement-scan", + Scope: "placement_topic", + ScopeID: scope.TopicID, + Payload: payload, + }) + if err != nil { + return nil, err + } + return &types.StartBrandScanJobData{ + JobID: run.ID.Hex(), + Status: string(run.Status), + Message: "雙軌海巡已在背景執行,完成後可到獲客台查看貼文", + }, nil +} diff --git a/haixun-backend/internal/logic/placement_topic/update_placement_topic_logic.go b/haixun-backend/internal/logic/placement_topic/update_placement_topic_logic.go new file mode 100644 index 0000000..f2c8a18 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/update_placement_topic_logic.go @@ -0,0 +1,39 @@ +package placement_topic + +import ( + "context" + + topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdatePlacementTopicLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdatePlacementTopicLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdatePlacementTopicLogic { + return &UpdatePlacementTopicLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdatePlacementTopicLogic) UpdatePlacementTopic(req *types.UpdatePlacementTopicHandlerReq) (*types.PlacementTopicData, error) { + tenantID, uid, err := actorFrom(l.ctx) + if err != nil { + return nil, err + } + item, err := l.svcCtx.PlacementTopic.Update(l.ctx, topicdomain.UpdateRequest{ + TenantID: tenantID, + OwnerUID: uid, + TopicID: req.ID, + Patch: toTopicPatch(&req.UpdatePlacementTopicReq), + }) + if err != nil { + return nil, err + } + data := toPlacementTopicData(*item) + return &data, nil +} diff --git a/haixun-backend/internal/logic/placement_topic/upsert_placement_topic_scan_schedule_logic.go b/haixun-backend/internal/logic/placement_topic/upsert_placement_topic_scan_schedule_logic.go new file mode 100644 index 0000000..56ef8c3 --- /dev/null +++ b/haixun-backend/internal/logic/placement_topic/upsert_placement_topic_scan_schedule_logic.go @@ -0,0 +1,35 @@ +package placement_topic + +import ( + "context" + + brandlogic "haixun-backend/internal/logic/brand" + "haixun-backend/internal/svc" + "haixun-backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpsertPlacementTopicScanScheduleLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpsertPlacementTopicScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPlacementTopicScanScheduleLogic { + return &UpsertPlacementTopicScanScheduleLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpsertPlacementTopicScanScheduleLogic) UpsertPlacementTopicScanSchedule(req *types.UpsertPlacementTopicScanScheduleHandlerReq) (*types.BrandScanScheduleData, error) { + if _, _, err := actorFrom(l.ctx); err != nil { + return nil, err + } + return brandlogic.NewUpsertBrandScanScheduleLogic(l.ctx, l.svcCtx).UpsertBrandScanSchedule(&types.UpsertBrandScanScheduleHandlerReq{ + BrandPath: types.BrandPath{ID: req.ID}, + UpsertBrandScanScheduleReq: types.UpsertBrandScanScheduleReq{ + Cron: req.Cron, + Timezone: req.Timezone, + Enabled: req.Enabled, + }, + }) +} diff --git a/haixun-backend/internal/model/brand/domain/entity/brand.go b/haixun-backend/internal/model/brand/domain/entity/brand.go index d29b2a7..d5bfeae 100644 --- a/haixun-backend/internal/model/brand/domain/entity/brand.go +++ b/haixun-backend/internal/model/brand/domain/entity/brand.go @@ -14,10 +14,13 @@ type Brand struct { TenantID string `bson:"tenant_id"` OwnerUID string `bson:"owner_uid"` DisplayName string `bson:"display_name,omitempty"` + TopicName string `bson:"topic_name,omitempty"` SeedQuery string `bson:"seed_query,omitempty"` Brief string `bson:"brief,omitempty"` ProductBrief string `bson:"product_brief,omitempty"` ProductContext string `bson:"product_context,omitempty"` + ProductID string `bson:"product_id,omitempty"` + Products []Product `bson:"products,omitempty"` TargetAudience string `bson:"target_audience,omitempty"` Goals string `bson:"goals,omitempty"` ResearchMap ResearchMap `bson:"research_map,omitempty"` diff --git a/haixun-backend/internal/model/brand/domain/entity/product.go b/haixun-backend/internal/model/brand/domain/entity/product.go new file mode 100644 index 0000000..573e57b --- /dev/null +++ b/haixun-backend/internal/model/brand/domain/entity/product.go @@ -0,0 +1,14 @@ +package entity + +type Product struct { + ID string `bson:"id"` + Label string `bson:"label"` + ProductContext string `bson:"product_context"` + MatchTags []string `bson:"match_tags,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +func (p Product) HasContext() bool { + return p.Label != "" || p.ProductContext != "" +} diff --git a/haixun-backend/internal/model/brand/domain/entity/research_map.go b/haixun-backend/internal/model/brand/domain/entity/research_map.go index 2a0e210..96bc5c4 100644 --- a/haixun-backend/internal/model/brand/domain/entity/research_map.go +++ b/haixun-backend/internal/model/brand/domain/entity/research_map.go @@ -1,11 +1,21 @@ package entity +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 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"` + 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"` + ResearchItems []ResearchItem `bson:"research_items,omitempty" json:"research_items,omitempty"` + ExpandStrategy string `bson:"expand_strategy,omitempty" json:"expand_strategy,omitempty"` + PatrolKeywords []string `bson:"patrol_keywords,omitempty" json:"patrol_keywords,omitempty"` } func (m ResearchMap) IsEmpty() bool { @@ -13,5 +23,6 @@ func (m ResearchMap) IsEmpty() bool { m.ContentGoal == "" && len(m.Questions) == 0 && len(m.Pillars) == 0 && - len(m.Exclusions) == 0 + len(m.Exclusions) == 0 && + len(m.ResearchItems) == 0 } diff --git a/haixun-backend/internal/model/brand/domain/repository/repository.go b/haixun-backend/internal/model/brand/domain/repository/repository.go index 02e6608..95b61a8 100644 --- a/haixun-backend/internal/model/brand/domain/repository/repository.go +++ b/haixun-backend/internal/model/brand/domain/repository/repository.go @@ -13,4 +13,7 @@ type Repository interface { ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Brand, error) Update(ctx context.Context, tenantID, ownerUID, brandID string, patch map[string]interface{}) (*entity.Brand, error) SoftDelete(ctx context.Context, tenantID, ownerUID, brandID string) error + PushProduct(ctx context.Context, tenantID, ownerUID, brandID string, product entity.Product) (*entity.Product, error) + UpdateProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string, patch map[string]interface{}) (*entity.Product, error) + PullProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error } diff --git a/haixun-backend/internal/model/brand/domain/usecase/usecase.go b/haixun-backend/internal/model/brand/domain/usecase/usecase.go index dfa7cd3..f9affa0 100644 --- a/haixun-backend/internal/model/brand/domain/usecase/usecase.go +++ b/haixun-backend/internal/model/brand/domain/usecase/usecase.go @@ -6,13 +6,25 @@ import ( "haixun-backend/internal/model/brand/domain/entity" ) +type ProductSummary struct { + ID string + Label string + ProductContext string + MatchTags []string + CreateAt int64 + UpdateAt int64 +} + type BrandSummary struct { ID string DisplayName string + TopicName string SeedQuery string Brief string ProductBrief string ProductContext string + ProductID string + Products []ProductSummary TargetAudience string Goals string ResearchMap entity.ResearchMap @@ -41,24 +53,64 @@ type UpdateRequest struct { } type BrandPatch struct { - DisplayName *string - SeedQuery *string - Brief *string - ProductBrief *string - ProductContext *string - TargetAudience *string - Goals *string - ResearchMap *entity.ResearchMap + DisplayName *string + TopicName *string + SeedQuery *string + Brief *string + ProductBrief *string + ProductContext *string + ProductID *string + TargetAudience *string + Goals *string + AudienceSummary *string + ContentGoal *string + Questions []string + QuestionsSet bool + Pillars []string + PillarsSet bool + Exclusions []string + ExclusionsSet bool + ResearchMap *entity.ResearchMap + PatrolKeywords []string + PatrolKeywordsSet bool } type ListResult struct { List []BrandSummary } +type ListProductsResult struct { + List []ProductSummary +} + +type CreateProductRequest struct { + TenantID string + OwnerUID string + BrandID string + Label string + ProductContext string + MatchTags []string +} + +type UpdateProductRequest struct { + TenantID string + OwnerUID string + BrandID string + ProductID string + Label *string + ProductContext *string + MatchTags []string + MatchTagsSet bool +} + type UseCase interface { List(ctx context.Context, tenantID, ownerUID string) (*ListResult, error) Create(ctx context.Context, req CreateRequest) (*BrandSummary, error) Get(ctx context.Context, tenantID, ownerUID, brandID string) (*BrandSummary, error) Update(ctx context.Context, req UpdateRequest) (*BrandSummary, error) Delete(ctx context.Context, tenantID, ownerUID, brandID string) error + ListProducts(ctx context.Context, tenantID, ownerUID, brandID string) (*ListProductsResult, error) + CreateProduct(ctx context.Context, req CreateProductRequest) (*ProductSummary, error) + UpdateProduct(ctx context.Context, req UpdateProductRequest) (*ProductSummary, error) + DeleteProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error } diff --git a/haixun-backend/internal/model/brand/repository/mongo.go b/haixun-backend/internal/model/brand/repository/mongo.go index 92dd018..2cd42a2 100644 --- a/haixun-backend/internal/model/brand/repository/mongo.go +++ b/haixun-backend/internal/model/brand/repository/mongo.go @@ -122,6 +122,99 @@ func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, br return nil } +func (r *mongoRepository) PushProduct(ctx context.Context, tenantID, ownerUID, brandID string, product entity.Product) (*entity.Product, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + now := clock.NowUnixNano() + product.CreateAt = now + product.UpdateAt = now + err := r.collection.FindOneAndUpdate( + ctx, + bson.M{"_id": brandID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen}, + bson.M{ + "$push": bson.M{"products": product}, + "$set": bson.M{"update_at": now}, + }, + options.FindOneAndUpdate().SetReturnDocument(options.After), + ).Err() + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("brand not found") + } + if err != nil { + return nil, err + } + return &product, nil +} + +func (r *mongoRepository) UpdateProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string, patch map[string]interface{}) (*entity.Product, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + if len(patch) == 0 { + return nil, app.For(code.Brand).InputMissingRequired("product patch is empty") + } + now := clock.NowUnixNano() + patch["products.$.update_at"] = now + set := bson.M{} + for key, value := range patch { + set[key] = value + } + set["update_at"] = now + + var out entity.Brand + err := r.collection.FindOneAndUpdate( + ctx, + bson.M{ + "_id": brandID, + "tenant_id": tenantID, + "owner_uid": ownerUID, + "status": entity.StatusOpen, + "products.id": productID, + }, + bson.M{"$set": set}, + options.FindOneAndUpdate().SetReturnDocument(options.After), + ).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("brand or product not found") + } + if err != nil { + return nil, err + } + for i := range out.Products { + if out.Products[i].ID == productID { + item := out.Products[i] + return &item, nil + } + } + return nil, app.For(code.Brand).ResNotFound("product not found") +} + +func (r *mongoRepository) PullProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error { + if r.collection == nil { + return app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + now := clock.NowUnixNano() + res, err := r.collection.UpdateOne( + ctx, + bson.M{"_id": brandID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen}, + bson.M{ + "$pull": bson.M{"products": bson.M{"id": productID}}, + "$set": bson.M{"update_at": now}, + }, + ) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return app.For(code.Brand).ResNotFound("brand not found") + } + if res.ModifiedCount == 0 { + return app.For(code.Brand).ResNotFound("product not found") + } + return nil +} + func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Brand, error) { if r.collection == nil { return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") diff --git a/haixun-backend/internal/model/brand/usecase/products.go b/haixun-backend/internal/model/brand/usecase/products.go new file mode 100644 index 0000000..03dc6de --- /dev/null +++ b/haixun-backend/internal/model/brand/usecase/products.go @@ -0,0 +1,162 @@ +package usecase + +import ( + "context" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/library/placement" + "haixun-backend/internal/model/brand/domain/entity" + domusecase "haixun-backend/internal/model/brand/domain/usecase" + + "github.com/google/uuid" +) + +func (u *brandUseCase) ListProducts(ctx context.Context, tenantID, ownerUID, brandID string) (*domusecase.ListProductsResult, error) { + item, err := u.assertOwned(ctx, tenantID, ownerUID, brandID) + if err != nil { + return nil, err + } + list := make([]domusecase.ProductSummary, 0, len(item.Products)) + for _, product := range item.Products { + list = append(list, toProductSummary(product)) + } + return &domusecase.ListProductsResult{List: list}, nil +} + +func (u *brandUseCase) CreateProduct(ctx context.Context, req domusecase.CreateProductRequest) (*domusecase.ProductSummary, error) { + if _, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.BrandID); err != nil { + return nil, err + } + label := strings.TrimSpace(req.Label) + contextRaw := strings.TrimSpace(req.ProductContext) + if label == "" { + return nil, app.For(code.Brand).InputMissingRequired("product label is required") + } + if !placement.HasProductContext(contextRaw) { + return nil, app.For(code.Brand).InputMissingRequired("product context is required") + } + product := entity.Product{ + ID: uuid.NewString(), + Label: label, + ProductContext: contextRaw, + MatchTags: normalizeMatchTags(req.MatchTags), + } + created, err := u.repo.PushProduct(ctx, req.TenantID, req.OwnerUID, req.BrandID, product) + if err != nil { + return nil, err + } + summary := toProductSummary(*created) + return &summary, nil +} + +func (u *brandUseCase) UpdateProduct(ctx context.Context, req domusecase.UpdateProductRequest) (*domusecase.ProductSummary, error) { + brand, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.BrandID) + if err != nil { + return nil, err + } + if placement.FindProduct(*brand, req.ProductID) == nil { + return nil, app.For(code.Brand).ResNotFound("product not found") + } + patch := map[string]interface{}{} + if req.Label != nil { + label := strings.TrimSpace(*req.Label) + if label == "" { + return nil, app.For(code.Brand).InputMissingRequired("product label is required") + } + patch["products.$.label"] = label + } + if req.ProductContext != nil { + contextRaw := strings.TrimSpace(*req.ProductContext) + if !placement.HasProductContext(contextRaw) { + return nil, app.For(code.Brand).InputMissingRequired("product context is required") + } + patch["products.$.product_context"] = contextRaw + } + if req.MatchTagsSet { + patch["products.$.match_tags"] = normalizeMatchTags(req.MatchTags) + } + if len(patch) == 0 { + return nil, app.For(code.Brand).InputMissingRequired("product patch is empty") + } + updated, err := u.repo.UpdateProduct(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.ProductID, patch) + if err != nil { + return nil, err + } + if brand.ProductID == req.ProductID { + _ = u.syncProductSnapshot(ctx, req.TenantID, req.OwnerUID, req.BrandID, brand.ProductID) + } + summary := toProductSummary(*updated) + return &summary, nil +} + +func (u *brandUseCase) DeleteProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error { + brand, err := u.assertOwned(ctx, tenantID, ownerUID, brandID) + if err != nil { + return err + } + if placement.FindProduct(*brand, productID) == nil { + return app.For(code.Brand).ResNotFound("product not found") + } + if err := u.repo.PullProduct(ctx, tenantID, ownerUID, brandID, productID); err != nil { + return err + } + if brand.ProductID == productID { + _, _ = u.repo.Update(ctx, tenantID, ownerUID, brandID, map[string]interface{}{ + "product_id": "", + "product_context": "", + }) + } + return nil +} + +func (u *brandUseCase) syncProductSnapshot(ctx context.Context, tenantID, ownerUID, brandID, productID string) error { + brand, err := u.assertOwned(ctx, tenantID, ownerUID, brandID) + if err != nil { + return err + } + snapshot := placement.ResolveBrandProductContext(*brand, productID, "") + patch := map[string]interface{}{ + "product_context": snapshot, + } + if id := strings.TrimSpace(productID); id != "" { + patch["product_id"] = id + } else { + patch["product_id"] = "" + } + _, err = u.repo.Update(ctx, tenantID, ownerUID, brandID, patch) + return err +} + +func toProductSummary(item entity.Product) domusecase.ProductSummary { + return domusecase.ProductSummary{ + ID: item.ID, + Label: item.Label, + ProductContext: item.ProductContext, + MatchTags: append([]string(nil), item.MatchTags...), + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} + +func normalizeMatchTags(tags []string) []string { + if len(tags) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]string, 0, len(tags)) + for _, raw := range tags { + tag := strings.TrimSpace(raw) + if tag == "" { + continue + } + key := strings.ToLower(tag) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, tag) + } + return out +} diff --git a/haixun-backend/internal/model/brand/usecase/usecase.go b/haixun-backend/internal/model/brand/usecase/usecase.go index 52c083c..57059f1 100644 --- a/haixun-backend/internal/model/brand/usecase/usecase.go +++ b/haixun-backend/internal/model/brand/usecase/usecase.go @@ -6,6 +6,8 @@ import ( app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" + libkg "haixun-backend/internal/library/knowledge" + "haixun-backend/internal/library/placement" "haixun-backend/internal/model/brand/domain/entity" domrepo "haixun-backend/internal/model/brand/domain/repository" domusecase "haixun-backend/internal/model/brand/domain/usecase" @@ -93,10 +95,15 @@ func (u *brandUseCase) Delete(ctx context.Context, tenantID, ownerUID, brandID s } func (u *brandUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.BrandSummary, error) { - if _, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.BrandID); err != nil { + brand, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.BrandID) + if err != nil { return nil, err } patch := patchToMap(req.Patch) + if req.Patch.ProductID != nil { + snapshot := placement.ResolveBrandProductContext(*brand, strings.TrimSpace(*req.Patch.ProductID), "") + patch["product_context"] = snapshot + } item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.BrandID, patch) if err != nil { return nil, err @@ -126,13 +133,20 @@ func toSummary(item *entity.Brand) domusecase.BrandSummary { if item == nil { return domusecase.BrandSummary{} } + products := make([]domusecase.ProductSummary, 0, len(item.Products)) + for _, product := range item.Products { + products = append(products, toProductSummary(product)) + } return domusecase.BrandSummary{ ID: item.ID, DisplayName: item.DisplayName, + TopicName: item.TopicName, SeedQuery: item.SeedQuery, Brief: item.Brief, ProductBrief: item.ProductBrief, ProductContext: item.ProductContext, + ProductID: item.ProductID, + Products: products, TargetAudience: item.TargetAudience, Goals: item.Goals, ResearchMap: item.ResearchMap, @@ -146,6 +160,9 @@ func patchToMap(patch domusecase.BrandPatch) map[string]interface{} { if patch.DisplayName != nil { out["display_name"] = strings.TrimSpace(*patch.DisplayName) } + if patch.TopicName != nil { + out["topic_name"] = strings.TrimSpace(*patch.TopicName) + } if patch.SeedQuery != nil { out["seed_query"] = strings.TrimSpace(*patch.SeedQuery) } @@ -158,15 +175,53 @@ func patchToMap(patch domusecase.BrandPatch) map[string]interface{} { if patch.ProductContext != nil { out["product_context"] = strings.TrimSpace(*patch.ProductContext) } + if patch.ProductID != nil { + out["product_id"] = strings.TrimSpace(*patch.ProductID) + } if patch.TargetAudience != nil { out["target_audience"] = strings.TrimSpace(*patch.TargetAudience) } if patch.Goals != nil { out["goals"] = strings.TrimSpace(*patch.Goals) } + if patch.AudienceSummary != nil { + out["research_map.audience_summary"] = strings.TrimSpace(*patch.AudienceSummary) + } + if patch.ContentGoal != nil { + out["research_map.content_goal"] = strings.TrimSpace(*patch.ContentGoal) + } + if patch.QuestionsSet { + out["research_map.questions"] = cleanStringList(patch.Questions) + } + if patch.PillarsSet { + out["research_map.pillars"] = cleanStringList(patch.Pillars) + } + if patch.ExclusionsSet { + out["research_map.exclusions"] = cleanStringList(patch.Exclusions) + } if patch.ResearchMap != nil { out["research_map"] = *patch.ResearchMap } + if patch.PatrolKeywordsSet { + out["research_map.patrol_keywords"] = libkg.NormalizePatrolKeywordList(patch.PatrolKeywords) + } + return out +} + +func cleanStringList(items []string) []string { + out := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } return out } diff --git a/haixun-backend/internal/model/job/usecase/refresh_lock_test.go b/haixun-backend/internal/model/job/usecase/refresh_lock_test.go new file mode 100644 index 0000000..fdb9b11 --- /dev/null +++ b/haixun-backend/internal/model/job/usecase/refresh_lock_test.go @@ -0,0 +1,39 @@ +package usecase + +import ( + "context" + "testing" + "time" + + "haixun-backend/internal/library/clock" + "haixun-backend/internal/model/job/domain/entity" + "haixun-backend/internal/model/job/domain/enum" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestRefreshRunLock_ExtendsMongoLockedUntil(t *testing.T) { + ctx := context.Background() + template := demoTemplate() + queue := newMemoryQueueRepo() + runs := newMemoryRunRepo(nil) + lockedUntil := clock.Now().Add(-time.Minute).UnixNano() + runs.run = &entity.Run{ + ID: primitive.NewObjectID(), + TemplateType: template.Type, + Status: enum.RunStatusRunning, + LockedBy: "worker-a", + LockedUntil: &lockedUntil, + } + uc := testUseCaseFull(template, runs, nil, queue) + + if _, err := queue.TryLock(ctx, runs.run.ID.Hex(), "worker-a", 600); err != nil { + t.Fatalf("TryLock() error = %v", err) + } + if err := uc.RefreshRunLock(ctx, runs.run.ID.Hex(), "worker-a", 600); err != nil { + t.Fatalf("RefreshRunLock() error = %v", err) + } + if runs.run.LockedUntil == nil || *runs.run.LockedUntil <= clock.NowUnixNano() { + t.Fatalf("locked_until = %v, want future timestamp", runs.run.LockedUntil) + } +} diff --git a/haixun-backend/internal/model/job/usecase/usecase.go b/haixun-backend/internal/model/job/usecase/usecase.go index 88c37f4..abe328b 100644 --- a/haixun-backend/internal/model/job/usecase/usecase.go +++ b/haixun-backend/internal/model/job/usecase/usecase.go @@ -117,7 +117,7 @@ func placementScanTemplate() *entity.Template { Repeatable: true, ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope), DedupeKeys: []string{"scope_id"}, - TimeoutSeconds: 900, + TimeoutSeconds: 7200, CancelPolicy: entity.CancelPolicy{ Supported: true, Mode: "cooperative", @@ -128,7 +128,7 @@ func placementScanTemplate() *entity.Template { BackoffSeconds: []int{}, }, Steps: []entity.TemplateStep{ - {ID: "crawl", Name: "Dual-track crawl", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 900, Cancelable: true}, + {ID: "crawl", Name: "Dual-track crawl", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 7200, Cancelable: true}, }, } } @@ -287,7 +287,7 @@ func (u *jobUseCase) enforceConcurrency(ctx context.Context, template *entity.Te case enum.ConcurrencyAllowParallel: return nil case enum.ConcurrencyRejectSameScope: - return app.For(code.Job).ResInvalidState("an active job already exists for this scope") + return app.For(code.Job).ResInvalidState("此範圍已有進行中的任務,請等待任務結束(含取消完成)後再試") case enum.ConcurrencyReplaceExisting: for _, run := range active { if _, err := u.RequestCancel(ctx, domusecase.CancelRunRequest{ @@ -299,7 +299,7 @@ func (u *jobUseCase) enforceConcurrency(ctx context.Context, template *entity.Te } return nil default: - return app.For(code.Job).ResInvalidState("an active job already exists for this scope") + return app.For(code.Job).ResInvalidState("此範圍已有進行中的任務,請等待任務結束(含取消完成)後再試") } } @@ -496,7 +496,7 @@ func (u *jobUseCase) ClaimNext(ctx context.Context, req domusecase.ClaimNextRequ continue } - ok, err := u.queue.TryLock(ctx, jobID, workerID, 300) + ok, err := u.queue.TryLock(ctx, jobID, workerID, 600) if err != nil { return nil, err } @@ -517,7 +517,7 @@ func (u *jobUseCase) ClaimNext(ctx context.Context, req domusecase.ClaimNextRequ now := clock.NowUnixNano() fromStatus := string(run.Status) - lockUntil := now + clock.SecondsToNanos(300) + lockUntil := now + clock.SecondsToNanos(600) run.Status = enum.RunStatusRunning run.LockedBy = workerID run.LockedUntil = &lockUntil @@ -543,7 +543,26 @@ func (u *jobUseCase) RefreshRunLock(ctx context.Context, jobID, workerID string, if jobID == "" || workerID == "" { return app.For(code.Job).InputMissingRequired("job id and worker id are required") } - return u.queue.RefreshLock(ctx, jobID, workerID, ttlSeconds) + if ttlSeconds <= 0 { + ttlSeconds = 300 + } + if err := u.queue.RefreshLock(ctx, jobID, workerID, ttlSeconds); err != nil { + return err + } + run, err := u.runs.FindByID(ctx, jobID) + if err != nil { + return err + } + if run.LockedBy != workerID { + return app.For(code.Job).ResInvalidState("job is locked by another worker") + } + lockUntil := clock.NowUnixNano() + clock.SecondsToNanos(ttlSeconds) + run.LockedUntil = &lockUntil + _, err = u.runs.UpdateIfLocked(ctx, run, workerID, []enum.RunStatus{ + enum.RunStatusRunning, + enum.RunStatusWaitingWorker, + }) + return err } func (u *jobUseCase) IsCancelRequested(ctx context.Context, jobID string) (bool, error) { @@ -619,6 +638,10 @@ func (u *jobUseCase) UpdateProgress(ctx context.Context, req domusecase.UpdatePr if len(req.Steps) > 0 { run.Progress.Steps = req.Steps } + if strings.TrimSpace(req.WorkerID) != "" && run.LockedBy == req.WorkerID { + lockUntil := clock.NowUnixNano() + clock.SecondsToNanos(600) + run.LockedUntil = &lockUntil + } return u.runs.UpdateIfLocked(ctx, run, req.WorkerID, []enum.RunStatus{enum.RunStatusRunning}) } diff --git a/haixun-backend/internal/model/knowledge_graph/domain/entity/graph.go b/haixun-backend/internal/model/knowledge_graph/domain/entity/graph.go index dd9076a..6eb4825 100644 --- a/haixun-backend/internal/model/knowledge_graph/domain/entity/graph.go +++ b/haixun-backend/internal/model/knowledge_graph/domain/entity/graph.go @@ -11,11 +11,13 @@ type Graph struct { TenantID string `bson:"tenant_id"` OwnerUID string `bson:"owner_uid"` BrandID string `bson:"brand_id"` + TopicID string `bson:"topic_id,omitempty"` LegacyPersonaID string `bson:"persona_id,omitempty"` Seed string `bson:"seed"` Nodes []libkg.Node `bson:"nodes"` Edges []libkg.Edge `bson:"edges"` BraveSources []libkg.BraveSource `bson:"brave_sources"` + ExpandStrategy string `bson:"expand_strategy,omitempty"` PainTagCount int `bson:"pain_tag_count"` GeneratedAt int64 `bson:"generated_at"` CreateAt int64 `bson:"create_at"` diff --git a/haixun-backend/internal/model/knowledge_graph/domain/repository/repository.go b/haixun-backend/internal/model/knowledge_graph/domain/repository/repository.go index a30af54..001ec48 100644 --- a/haixun-backend/internal/model/knowledge_graph/domain/repository/repository.go +++ b/haixun-backend/internal/model/knowledge_graph/domain/repository/repository.go @@ -10,6 +10,10 @@ import ( type Repository interface { EnsureIndexes(ctx context.Context) error UpsertByBrand(ctx context.Context, graph *entity.Graph) (*entity.Graph, error) + UpsertByTopic(ctx context.Context, graph *entity.Graph) (*entity.Graph, error) FindByBrand(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.Graph, error) + FindByTopic(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Graph, error) UpdateNodes(ctx context.Context, tenantID, ownerUID, brandID string, nodes []libkg.Node, painTagCount int) (*entity.Graph, error) + UpdateNodesByTopic(ctx context.Context, tenantID, ownerUID, topicID string, nodes []libkg.Node, painTagCount int) (*entity.Graph, error) + AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error } diff --git a/haixun-backend/internal/model/knowledge_graph/domain/usecase/usecase.go b/haixun-backend/internal/model/knowledge_graph/domain/usecase/usecase.go index 468f857..203e501 100644 --- a/haixun-backend/internal/model/knowledge_graph/domain/usecase/usecase.go +++ b/haixun-backend/internal/model/knowledge_graph/domain/usecase/usecase.go @@ -7,44 +7,55 @@ import ( ) type GraphSummary struct { - ID string - BrandID string - Seed string - Nodes []libkg.Node - Edges []libkg.Edge - BraveSources []libkg.BraveSource - PainTagCount int - GeneratedAt int64 - CreateAt int64 - UpdateAt int64 + ID string + BrandID string + TopicID string + Seed string + Nodes []libkg.Node + Edges []libkg.Edge + BraveSources []libkg.BraveSource + ExpandStrategy string + PainTagCount int + GeneratedAt int64 + CreateAt int64 + UpdateAt int64 } type UpsertRequest struct { - TenantID string - OwnerUID string - BrandID string - Seed string - Nodes []libkg.Node - Edges []libkg.Edge - BraveSources []libkg.BraveSource - PainTagCount int - GeneratedAt int64 + TenantID string + OwnerUID string + BrandID string + TopicID string + Seed string + Nodes []libkg.Node + Edges []libkg.Edge + BraveSources []libkg.BraveSource + ExpandStrategy string + PainTagCount int + GeneratedAt int64 } -type NodeSelectionUpdate struct { - NodeID string - SelectedForScan bool +type NodeUpdate struct { + NodeID string + SelectedForScan *bool + RelevanceTags []string + RelevanceTagsSet bool + RecencyTags []string + RecencyTagsSet bool } type UpdateNodesRequest struct { TenantID string OwnerUID string BrandID string - Updates []NodeSelectionUpdate + TopicID string + Updates []NodeUpdate } type UseCase interface { Get(ctx context.Context, tenantID, ownerUID, brandID string) (*GraphSummary, error) + GetByTopic(ctx context.Context, tenantID, ownerUID, topicID string) (*GraphSummary, error) Upsert(ctx context.Context, req UpsertRequest) (*GraphSummary, error) - UpdateNodeSelections(ctx context.Context, req UpdateNodesRequest) (*GraphSummary, error) + UpdateNodes(ctx context.Context, req UpdateNodesRequest) (*GraphSummary, error) + AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error } diff --git a/haixun-backend/internal/model/knowledge_graph/repository/mongo.go b/haixun-backend/internal/model/knowledge_graph/repository/mongo.go index 2f23369..01abbbb 100644 --- a/haixun-backend/internal/model/knowledge_graph/repository/mongo.go +++ b/haixun-backend/internal/model/knowledge_graph/repository/mongo.go @@ -34,9 +34,11 @@ func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { return nil } return libmongo.EnsureIndexes(ctx, r.collection, []mongo.IndexModel{ - {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"brand_id": bson.M{"$gt": ""}})}, + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"brand_id": bson.M{"$gt": ""}, "topic_id": bson.M{"$in": []interface{}{nil, ""}}})}, + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "topic_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"topic_id": bson.M{"$gt": ""}})}, {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"persona_id": bson.M{"$gt": ""}})}, {Keys: bson.D{{Key: "brand_id", Value: 1}, {Key: "update_at", Value: -1}}}, + {Keys: bson.D{{Key: "topic_id", Value: 1}, {Key: "update_at", Value: -1}}}, {Keys: bson.D{{Key: "persona_id", Value: 1}, {Key: "update_at", Value: -1}}}, }) } @@ -52,6 +54,14 @@ func brandOwnerFilter(tenantID, ownerUID, brandID string) bson.M { return filter } +func topicOwnerFilter(tenantID, ownerUID, topicID string) bson.M { + return bson.M{ + "tenant_id": tenantID, + "owner_uid": ownerUID, + "topic_id": strings.TrimSpace(topicID), + } +} + func (r *mongoRepository) UpsertByBrand(ctx context.Context, graph *entity.Graph) (*entity.Graph, error) { if r.collection == nil { return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") @@ -71,14 +81,15 @@ func (r *mongoRepository) UpsertByBrand(ctx context.Context, graph *entity.Graph filter := brandOwnerFilter(graph.TenantID, graph.OwnerUID, graph.BrandID) update := bson.M{ "$set": bson.M{ - "seed": graph.Seed, - "nodes": graph.Nodes, - "edges": graph.Edges, - "brave_sources": graph.BraveSources, - "pain_tag_count": graph.PainTagCount, - "generated_at": graph.GeneratedAt, - "update_at": graph.UpdateAt, - "brand_id": graph.BrandID, + "seed": graph.Seed, + "nodes": graph.Nodes, + "edges": graph.Edges, + "brave_sources": graph.BraveSources, + "expand_strategy": graph.ExpandStrategy, + "pain_tag_count": graph.PainTagCount, + "generated_at": graph.GeneratedAt, + "update_at": graph.UpdateAt, + "brand_id": graph.BrandID, }, "$setOnInsert": bson.M{ "_id": graph.ID, @@ -96,6 +107,109 @@ func (r *mongoRepository) UpsertByBrand(ctx context.Context, graph *entity.Graph return &out, nil } +func (r *mongoRepository) UpsertByTopic(ctx context.Context, graph *entity.Graph) (*entity.Graph, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + if graph == nil || strings.TrimSpace(graph.TopicID) == "" { + return nil, app.For(code.Brand).InputMissingRequired("topic_id is required") + } + now := clock.NowUnixNano() + graph.UpdateAt = now + if graph.CreateAt == 0 { + graph.CreateAt = now + } + if strings.TrimSpace(graph.ID) == "" { + graph.ID = uuid.NewString() + } + + filter := topicOwnerFilter(graph.TenantID, graph.OwnerUID, graph.TopicID) + update := bson.M{ + "$set": bson.M{ + "seed": graph.Seed, + "nodes": graph.Nodes, + "edges": graph.Edges, + "brave_sources": graph.BraveSources, + "expand_strategy": graph.ExpandStrategy, + "pain_tag_count": graph.PainTagCount, + "generated_at": graph.GeneratedAt, + "update_at": graph.UpdateAt, + "brand_id": graph.BrandID, + "topic_id": graph.TopicID, + }, + "$setOnInsert": bson.M{ + "_id": graph.ID, + "tenant_id": graph.TenantID, + "owner_uid": graph.OwnerUID, + "create_at": graph.CreateAt, + }, + } + opts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After) + var out entity.Graph + err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out) + if err != nil { + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) FindByTopic(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Graph, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + var out entity.Graph + err := r.collection.FindOne(ctx, topicOwnerFilter(tenantID, ownerUID, topicID)).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + if err != nil { + return nil, err + } + return &out, nil +} + +func (r *mongoRepository) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error { + if r.collection == nil { + return app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.UpdateOne( + ctx, + brandOwnerFilter(tenantID, ownerUID, brandID), + bson.M{"$set": bson.M{"topic_id": strings.TrimSpace(topicID)}}, + ) + return err +} + +func (r *mongoRepository) UpdateNodesByTopic( + ctx context.Context, + tenantID, ownerUID, topicID string, + nodes []libkg.Node, + painTagCount int, +) (*entity.Graph, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + now := clock.NowUnixNano() + var out entity.Graph + err := r.collection.FindOneAndUpdate( + ctx, + topicOwnerFilter(tenantID, ownerUID, topicID), + bson.M{"$set": bson.M{ + "nodes": nodes, + "pain_tag_count": painTagCount, + "update_at": now, + }}, + options.FindOneAndUpdate().SetReturnDocument(options.After), + ).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("knowledge graph not found") + } + if err != nil { + return nil, err + } + return &out, nil +} + func (r *mongoRepository) FindByBrand(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.Graph, error) { if r.collection == nil { return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") diff --git a/haixun-backend/internal/model/knowledge_graph/usecase/usecase.go b/haixun-backend/internal/model/knowledge_graph/usecase/usecase.go index 7f7c472..f814231 100644 --- a/haixun-backend/internal/model/knowledge_graph/usecase/usecase.go +++ b/haixun-backend/internal/model/knowledge_graph/usecase/usecase.go @@ -22,7 +22,7 @@ func NewUseCase(repo domrepo.Repository) domusecase.UseCase { } func (u *knowledgeGraphUseCase) Get(ctx context.Context, tenantID, ownerUID, brandID string) (*domusecase.GraphSummary, error) { - if err := requireActor(tenantID, ownerUID, brandID); err != nil { + if err := requireScope(tenantID, ownerUID, brandID, ""); err != nil { return nil, err } item, err := u.repo.FindByBrand(ctx, tenantID, ownerUID, brandID) @@ -33,25 +33,50 @@ func (u *knowledgeGraphUseCase) Get(ctx context.Context, tenantID, ownerUID, bra return &summary, nil } +func (u *knowledgeGraphUseCase) GetByTopic(ctx context.Context, tenantID, ownerUID, topicID string) (*domusecase.GraphSummary, error) { + if err := requireScope(tenantID, ownerUID, "", topicID); err != nil { + return nil, err + } + item, err := u.repo.FindByTopic(ctx, tenantID, ownerUID, topicID) + if err != nil { + return nil, err + } + summary := toSummary(item) + return &summary, nil +} + func (u *knowledgeGraphUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.GraphSummary, error) { - if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { + topicID := strings.TrimSpace(req.TopicID) + brandID := strings.TrimSpace(req.BrandID) + if err := requireScope(req.TenantID, req.OwnerUID, brandID, topicID); err != nil { return nil, err } seed := strings.TrimSpace(req.Seed) if seed == "" { return nil, app.For(code.Brand).InputMissingRequired("seed is required") } - item, err := u.repo.UpsertByBrand(ctx, &entity.Graph{ - TenantID: req.TenantID, - OwnerUID: req.OwnerUID, - BrandID: req.BrandID, - Seed: seed, - Nodes: req.Nodes, - Edges: req.Edges, - BraveSources: req.BraveSources, - PainTagCount: req.PainTagCount, - GeneratedAt: req.GeneratedAt, - }) + graph := &entity.Graph{ + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + BrandID: brandID, + TopicID: topicID, + Seed: seed, + Nodes: req.Nodes, + Edges: req.Edges, + BraveSources: req.BraveSources, + ExpandStrategy: req.ExpandStrategy, + PainTagCount: req.PainTagCount, + GeneratedAt: req.GeneratedAt, + } + var ( + item *entity.Graph + err error + ) + if topicID != "" { + item, err = u.repo.UpsertByTopic(ctx, graph) + } else { + item, err = u.repo.UpsertByBrand(ctx, graph) + } if err != nil { return nil, err } @@ -59,34 +84,62 @@ func (u *knowledgeGraphUseCase) Upsert(ctx context.Context, req domusecase.Upser return &summary, nil } -func (u *knowledgeGraphUseCase) UpdateNodeSelections(ctx context.Context, req domusecase.UpdateNodesRequest) (*domusecase.GraphSummary, error) { - if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { +func (u *knowledgeGraphUseCase) UpdateNodes(ctx context.Context, req domusecase.UpdateNodesRequest) (*domusecase.GraphSummary, error) { + topicID := strings.TrimSpace(req.TopicID) + brandID := strings.TrimSpace(req.BrandID) + if err := requireScope(req.TenantID, req.OwnerUID, brandID, topicID); err != nil { return nil, err } if len(req.Updates) == 0 { return nil, app.For(code.Brand).InputMissingRequired("updates is required") } - current, err := u.repo.FindByBrand(ctx, req.TenantID, req.OwnerUID, req.BrandID) + var ( + current *entity.Graph + err error + ) + if topicID != "" { + current, err = u.repo.FindByTopic(ctx, req.TenantID, req.OwnerUID, topicID) + } else { + current, err = u.repo.FindByBrand(ctx, req.TenantID, req.OwnerUID, brandID) + } if err != nil { return nil, err } - selections := map[string]bool{} + changes := map[string]domusecase.NodeUpdate{} for _, update := range req.Updates { id := strings.TrimSpace(update.NodeID) if id == "" { continue } - selections[id] = update.SelectedForScan + changes[id] = update } nodes := make([]libkg.Node, len(current.Nodes)) copy(nodes, current.Nodes) for i := range nodes { - if selected, ok := selections[nodes[i].ID]; ok { - nodes[i].SelectedForScan = selected + update, ok := changes[nodes[i].ID] + if !ok { + continue + } + if update.SelectedForScan != nil { + nodes[i].SelectedForScan = *update.SelectedForScan + } + if update.RelevanceTagsSet { + nodes[i].PatrolRelevance = libkg.SanitizePatrolKeywordList(update.RelevanceTags) + } + if update.RecencyTagsSet { + nodes[i].PatrolRecency = libkg.SanitizePatrolKeywordList(update.RecencyTags) + } + if update.RelevanceTagsSet || update.RecencyTagsSet { + nodes[i].DerivedTags = libkg.DerivePatrolTagsForNode(nodes[i], libkg.PatrolTagInput{}) } } painCount := libkg.CountPainTagCandidates(nodes) - item, err := u.repo.UpdateNodes(ctx, req.TenantID, req.OwnerUID, req.BrandID, nodes, painCount) + var item *entity.Graph + if topicID != "" { + item, err = u.repo.UpdateNodesByTopic(ctx, req.TenantID, req.OwnerUID, topicID, nodes, painCount) + } else { + item, err = u.repo.UpdateNodes(ctx, req.TenantID, req.OwnerUID, brandID, nodes, painCount) + } if err != nil { return nil, err } @@ -94,12 +147,19 @@ func (u *knowledgeGraphUseCase) UpdateNodeSelections(ctx context.Context, req do return &summary, nil } -func requireActor(tenantID, ownerUID, brandID string) error { +func (u *knowledgeGraphUseCase) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error { + if err := requireScope(tenantID, ownerUID, brandID, topicID); err != nil { + return err + } + return u.repo.AttachTopicID(ctx, tenantID, ownerUID, brandID, topicID) +} + +func requireScope(tenantID, ownerUID, brandID, topicID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return app.For(code.Brand).InputMissingRequired("tenant_id and uid are required") } - if strings.TrimSpace(brandID) == "" { - return app.For(code.Brand).InputMissingRequired("brand id is required") + if strings.TrimSpace(topicID) == "" && strings.TrimSpace(brandID) == "" { + return app.For(code.Brand).InputMissingRequired("brand id or topic id is required") } return nil } @@ -109,15 +169,17 @@ func toSummary(item *entity.Graph) domusecase.GraphSummary { return domusecase.GraphSummary{} } return domusecase.GraphSummary{ - ID: item.ID, - BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID), - Seed: item.Seed, - Nodes: item.Nodes, - Edges: item.Edges, - BraveSources: item.BraveSources, - PainTagCount: item.PainTagCount, - GeneratedAt: item.GeneratedAt, - UpdateAt: item.UpdateAt, - CreateAt: item.CreateAt, + ID: item.ID, + BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID), + TopicID: strings.TrimSpace(item.TopicID), + Seed: item.Seed, + Nodes: item.Nodes, + Edges: item.Edges, + BraveSources: item.BraveSources, + ExpandStrategy: item.ExpandStrategy, + PainTagCount: item.PainTagCount, + GeneratedAt: item.GeneratedAt, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, } } diff --git a/haixun-backend/internal/model/placement/usecase/settings.go b/haixun-backend/internal/model/placement/usecase/settings.go index 0338e4f..da7f850 100644 --- a/haixun-backend/internal/model/placement/usecase/settings.go +++ b/haixun-backend/internal/model/placement/usecase/settings.go @@ -6,6 +6,7 @@ import ( app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" + libkg "haixun-backend/internal/library/knowledge" "haixun-backend/internal/library/placement" settingdomain "haixun-backend/internal/model/setting/domain/usecase" ) @@ -20,12 +21,14 @@ type Settings struct { BraveAPIKeyConfigured bool BraveCountry string BraveSearchLang string + ExpandStrategy string } type SettingsPatch struct { BraveAPIKey *string BraveCountry *string BraveSearchLang *string + ExpandStrategy *string } type UseCase interface { @@ -80,6 +83,7 @@ func (u *placementUseCase) ResearchSettings(ctx context.Context, tenantID, owner BraveAPIKey: stored.BraveAPIKey, BraveCountry: stored.BraveCountry, BraveSearchLang: stored.BraveSearchLang, + ExpandStrategy: stored.ExpandStrategy, }, nil } @@ -109,12 +113,14 @@ type storedSettings struct { BraveAPIKey string BraveCountry string BraveSearchLang string + ExpandStrategy string } func defaultSettings() storedSettings { return storedSettings{ BraveCountry: "tw", BraveSearchLang: "zh-hant", + ExpandStrategy: string(libkg.ExpandStrategyHybrid), } } @@ -131,6 +137,9 @@ func mergeSettings(defaults storedSettings, raw map[string]interface{}) storedSe if v, ok := raw["brave_search_lang"].(string); ok && strings.TrimSpace(v) != "" { defaults.BraveSearchLang = strings.TrimSpace(v) } + if v, ok := raw["expand_strategy"].(string); ok && strings.TrimSpace(v) != "" { + defaults.ExpandStrategy = string(libkg.ParseExpandStrategy(v)) + } return defaults } @@ -147,6 +156,9 @@ func applyPatch(current storedSettings, patch SettingsPatch) storedSettings { if patch.BraveSearchLang != nil && strings.TrimSpace(*patch.BraveSearchLang) != "" { current.BraveSearchLang = strings.TrimSpace(*patch.BraveSearchLang) } + if patch.ExpandStrategy != nil && strings.TrimSpace(*patch.ExpandStrategy) != "" { + current.ExpandStrategy = string(libkg.ParseExpandStrategy(*patch.ExpandStrategy)) + } return current } @@ -155,6 +167,7 @@ func (s storedSettings) toMap() map[string]interface{} { "brave_api_key": s.BraveAPIKey, "brave_country": s.BraveCountry, "brave_search_lang": s.BraveSearchLang, + "expand_strategy": s.ExpandStrategy, } } @@ -163,11 +176,13 @@ func toPublic(stored storedSettings) *Settings { if stored.BraveAPIKey != "" { masked = maskAPIKey(stored.BraveAPIKey) } + strategy := string(libkg.ParseExpandStrategy(stored.ExpandStrategy)) return &Settings{ BraveAPIKey: masked, BraveAPIKeyConfigured: strings.TrimSpace(stored.BraveAPIKey) != "", BraveCountry: stored.BraveCountry, BraveSearchLang: stored.BraveSearchLang, + ExpandStrategy: strategy, } } diff --git a/haixun-backend/internal/model/placement_topic/domain/entity/topic.go b/haixun-backend/internal/model/placement_topic/domain/entity/topic.go new file mode 100644 index 0000000..44227f7 --- /dev/null +++ b/haixun-backend/internal/model/placement_topic/domain/entity/topic.go @@ -0,0 +1,34 @@ +package entity + +import brandentity "haixun-backend/internal/model/brand/domain/entity" + +const CollectionName = "placement_topics" + +type Status string + +const ( + StatusOpen Status = "open" + StatusDeleted Status = "deleted" +) + +type Topic struct { + ID string `bson:"_id"` + TenantID string `bson:"tenant_id"` + OwnerUID string `bson:"owner_uid"` + BrandID string `bson:"brand_id"` + TopicName string `bson:"topic_name,omitempty"` + SeedQuery string `bson:"seed_query,omitempty"` + Brief string `bson:"brief,omitempty"` + ProductID string `bson:"product_id,omitempty"` + ResearchMap brandentity.ResearchMap `bson:"research_map,omitempty"` + Status Status `bson:"status"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +func (t Topic) HasPlacementSignals() bool { + return t.TopicName != "" || + t.SeedQuery != "" || + t.Brief != "" || + !t.ResearchMap.IsEmpty() +} diff --git a/haixun-backend/internal/model/placement_topic/domain/repository/repository.go b/haixun-backend/internal/model/placement_topic/domain/repository/repository.go new file mode 100644 index 0000000..66ff906 --- /dev/null +++ b/haixun-backend/internal/model/placement_topic/domain/repository/repository.go @@ -0,0 +1,17 @@ +package repository + +import ( + "context" + + "haixun-backend/internal/model/placement_topic/domain/entity" +) + +type Repository interface { + EnsureIndexes(ctx context.Context) error + Create(ctx context.Context, topic *entity.Topic) (*entity.Topic, error) + FindByID(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Topic, error) + ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Topic, error) + ListByBrand(ctx context.Context, tenantID, ownerUID, brandID string) ([]*entity.Topic, error) + Update(ctx context.Context, tenantID, ownerUID, topicID string, patch map[string]interface{}) (*entity.Topic, error) + SoftDelete(ctx context.Context, tenantID, ownerUID, topicID string) error +} diff --git a/haixun-backend/internal/model/placement_topic/domain/usecase/usecase.go b/haixun-backend/internal/model/placement_topic/domain/usecase/usecase.go new file mode 100644 index 0000000..7b461c1 --- /dev/null +++ b/haixun-backend/internal/model/placement_topic/domain/usecase/usecase.go @@ -0,0 +1,68 @@ +package usecase + +import ( + "context" + + brandentity "haixun-backend/internal/model/brand/domain/entity" +) + +type TopicSummary struct { + ID string + BrandID string + BrandDisplayName string + TopicName string + SeedQuery string + Brief string + ProductID string + ResearchMap brandentity.ResearchMap + CreateAt int64 + UpdateAt int64 +} + +type CreateRequest struct { + TenantID string + OwnerUID string + BrandID string + TopicName string + SeedQuery string + Brief string + ProductID string +} + +type UpdateRequest struct { + TenantID string + OwnerUID string + TopicID string + Patch TopicPatch +} + +type TopicPatch struct { + BrandID *string + TopicName *string + SeedQuery *string + Brief *string + ProductID *string + AudienceSummary *string + ContentGoal *string + Questions []string + QuestionsSet bool + Pillars []string + PillarsSet bool + Exclusions []string + ExclusionsSet bool + PatrolKeywords []string + PatrolKeywordsSet bool + ResearchMap *brandentity.ResearchMap +} + +type ListResult struct { + List []TopicSummary +} + +type UseCase interface { + List(ctx context.Context, tenantID, ownerUID string) (*ListResult, error) + Create(ctx context.Context, req CreateRequest) (*TopicSummary, error) + Get(ctx context.Context, tenantID, ownerUID, topicID string) (*TopicSummary, error) + Update(ctx context.Context, req UpdateRequest) (*TopicSummary, error) + Delete(ctx context.Context, tenantID, ownerUID, topicID string) error +} diff --git a/haixun-backend/internal/model/placement_topic/repository/mongo.go b/haixun-backend/internal/model/placement_topic/repository/mongo.go new file mode 100644 index 0000000..91934cb --- /dev/null +++ b/haixun-backend/internal/model/placement_topic/repository/mongo.go @@ -0,0 +1,164 @@ +package repository + +import ( + "context" + "strings" + + "haixun-backend/internal/library/clock" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + "haixun-backend/internal/model/placement_topic/domain/entity" + domrepo "haixun-backend/internal/model/placement_topic/domain/repository" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type mongoRepository struct { + collection *mongo.Collection +} + +func NewMongoRepository(db *mongo.Database) domrepo.Repository { + if db == nil { + return &mongoRepository{} + } + return &mongoRepository{collection: db.Collection(entity.CollectionName)} +} + +func (r *mongoRepository) EnsureIndexes(ctx context.Context) error { + if r.collection == nil { + return nil + } + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "update_at", Value: -1}}}, + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}}}, + {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "_id", Value: 1}}, Options: options.Index().SetUnique(true)}, + }) + return err +} + +func (r *mongoRepository) Create(ctx context.Context, topic *entity.Topic) (*entity.Topic, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + now := clock.NowUnixNano() + topic.CreateAt = now + topic.UpdateAt = now + if topic.Status == "" { + topic.Status = entity.StatusOpen + } + _, err := r.collection.InsertOne(ctx, topic) + if err != nil { + return nil, err + } + return topic, nil +} + +func (r *mongoRepository) FindByID(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Topic, error) { + return r.findOne(ctx, bson.M{ + "_id": strings.TrimSpace(topicID), + "tenant_id": tenantID, + "owner_uid": ownerUID, + "status": entity.StatusOpen, + }) +} + +func (r *mongoRepository) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Topic, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + cursor, err := r.collection.Find( + ctx, + bson.M{"tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen}, + options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}), + ) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var items []*entity.Topic + if err := cursor.All(ctx, &items); err != nil { + return nil, err + } + return items, nil +} + +func (r *mongoRepository) ListByBrand(ctx context.Context, tenantID, ownerUID, brandID string) ([]*entity.Topic, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + cursor, err := r.collection.Find( + ctx, + bson.M{ + "tenant_id": tenantID, + "owner_uid": ownerUID, + "brand_id": strings.TrimSpace(brandID), + "status": entity.StatusOpen, + }, + options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}), + ) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var items []*entity.Topic + if err := cursor.All(ctx, &items); err != nil { + return nil, err + } + return items, nil +} + +func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, topicID string, patch map[string]interface{}) (*entity.Topic, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + if len(patch) == 0 { + return r.FindByID(ctx, tenantID, ownerUID, topicID) + } + patch["update_at"] = clock.NowUnixNano() + var out entity.Topic + err := r.collection.FindOneAndUpdate( + ctx, + bson.M{"_id": topicID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen}, + bson.M{"$set": patch}, + options.FindOneAndUpdate().SetReturnDocument(options.After), + ).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("placement topic not found") + } + return &out, err +} + +func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, topicID string) error { + if r.collection == nil { + return app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + res, err := r.collection.UpdateOne( + ctx, + bson.M{"_id": topicID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen}, + bson.M{"$set": bson.M{"status": entity.StatusDeleted, "update_at": clock.NowUnixNano()}}, + ) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return app.For(code.Brand).ResNotFound("placement topic not found") + } + return nil +} + +func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Topic, error) { + if r.collection == nil { + return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + var out entity.Topic + err := r.collection.FindOne(ctx, filter).Decode(&out) + if err == mongo.ErrNoDocuments { + return nil, app.For(code.Brand).ResNotFound("placement topic not found") + } + if err != nil { + return nil, err + } + return &out, nil +} diff --git a/haixun-backend/internal/model/placement_topic/usecase/usecase.go b/haixun-backend/internal/model/placement_topic/usecase/usecase.go new file mode 100644 index 0000000..b8f747b --- /dev/null +++ b/haixun-backend/internal/model/placement_topic/usecase/usecase.go @@ -0,0 +1,392 @@ +package usecase + +import ( + "context" + "reflect" + "strings" + + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" + libkg "haixun-backend/internal/library/knowledge" + brandentity "haixun-backend/internal/model/brand/domain/entity" + brandrepo "haixun-backend/internal/model/brand/domain/repository" + kgdomusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" + "haixun-backend/internal/model/placement_topic/domain/entity" + domrepo "haixun-backend/internal/model/placement_topic/domain/repository" + domusecase "haixun-backend/internal/model/placement_topic/domain/usecase" + scanpostrepo "haixun-backend/internal/model/scan_post/domain/repository" + + "github.com/google/uuid" +) + +type topicUseCase struct { + repo domrepo.Repository + brandRepo brandrepo.Repository + kg kgdomusecase.UseCase + scanRepo scanpostrepo.Repository +} + +func NewUseCase( + repo domrepo.Repository, + brandRepo brandrepo.Repository, + kg kgdomusecase.UseCase, + scanRepo scanpostrepo.Repository, +) domusecase.UseCase { + return &topicUseCase{repo: repo, brandRepo: brandRepo, kg: kg, scanRepo: scanRepo} +} + +func (u *topicUseCase) List(ctx context.Context, tenantID, ownerUID string) (*domusecase.ListResult, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + if err := u.migrateLegacyBrands(ctx, tenantID, ownerUID); err != nil { + return nil, err + } + items, err := u.repo.ListByOwner(ctx, tenantID, ownerUID) + if err != nil { + return nil, err + } + brandNames, err := u.brandDisplayNames(ctx, tenantID, ownerUID) + if err != nil { + return nil, err + } + list := make([]domusecase.TopicSummary, 0, len(items)) + for _, item := range items { + list = append(list, toSummary(item, brandNames[item.BrandID])) + } + return &domusecase.ListResult{List: list}, nil +} + +func (u *topicUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.TopicSummary, error) { + if err := requireActor(req.TenantID, req.OwnerUID); err != nil { + return nil, err + } + brandID := strings.TrimSpace(req.BrandID) + if brandID == "" { + return nil, app.For(code.Brand).InputMissingRequired("brand_id is required") + } + topicName := strings.TrimSpace(req.TopicName) + seedQuery := strings.TrimSpace(req.SeedQuery) + brief := strings.TrimSpace(req.Brief) + if topicName == "" { + return nil, app.For(code.Brand).InputMissingRequired("topic_name is required") + } + if seedQuery == "" || brief == "" { + return nil, app.For(code.Brand).InputMissingRequired("seed_query and brief are required") + } + brand, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, brandID) + if err != nil { + return nil, err + } + productID := strings.TrimSpace(req.ProductID) + if productID != "" && !brandHasProduct(brand, productID) { + return nil, app.For(code.Brand).InputMissingRequired("product not found on brand") + } + topic := &entity.Topic{ + ID: uuid.NewString(), + TenantID: req.TenantID, + OwnerUID: req.OwnerUID, + BrandID: brandID, + TopicName: topicName, + SeedQuery: seedQuery, + Brief: brief, + ProductID: productID, + Status: entity.StatusOpen, + } + item, err := u.repo.Create(ctx, topic) + if err != nil { + return nil, err + } + summary := toSummary(item, brand.DisplayName) + return &summary, nil +} + +func (u *topicUseCase) Get(ctx context.Context, tenantID, ownerUID, topicID string) (*domusecase.TopicSummary, error) { + item, err := u.assertOwned(ctx, tenantID, ownerUID, topicID) + if err != nil { + return nil, err + } + displayName := "" + if brand, err := u.brandRepo.FindByID(ctx, tenantID, ownerUID, item.BrandID); err == nil && brand != nil { + displayName = brand.DisplayName + } + summary := toSummary(item, displayName) + return &summary, nil +} + +func (u *topicUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.TopicSummary, error) { + topic, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.TopicID) + if err != nil { + return nil, err + } + if req.Patch.BrandID != nil { + brandID := strings.TrimSpace(*req.Patch.BrandID) + if brandID == "" { + return nil, app.For(code.Brand).InputMissingRequired("brand_id is required") + } + if _, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, brandID); err != nil { + return nil, err + } + } + if req.Patch.ProductID != nil { + productID := strings.TrimSpace(*req.Patch.ProductID) + if productID != "" { + brandID := topic.BrandID + if req.Patch.BrandID != nil { + brandID = strings.TrimSpace(*req.Patch.BrandID) + } + brand, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, brandID) + if err != nil { + return nil, err + } + if !brandHasProduct(brand, productID) { + return nil, app.For(code.Brand).InputMissingRequired("product not found on brand") + } + } + } + patch := patchToMap(req.Patch) + item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.TopicID, patch) + if err != nil { + return nil, err + } + displayName := "" + if brand, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, item.BrandID); err == nil && brand != nil { + displayName = brand.DisplayName + } + summary := toSummary(item, displayName) + return &summary, nil +} + +func (u *topicUseCase) Delete(ctx context.Context, tenantID, ownerUID, topicID string) error { + if _, err := u.assertOwned(ctx, tenantID, ownerUID, topicID); err != nil { + return err + } + return u.repo.SoftDelete(ctx, tenantID, ownerUID, topicID) +} + +func (u *topicUseCase) assertOwned(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Topic, error) { + if err := requireActor(tenantID, ownerUID); err != nil { + return nil, err + } + if strings.TrimSpace(topicID) == "" { + return nil, app.For(code.Brand).InputMissingRequired("topic id is required") + } + return u.repo.FindByID(ctx, tenantID, ownerUID, topicID) +} + +func (u *topicUseCase) migrateLegacyBrands(ctx context.Context, tenantID, ownerUID string) error { + if u.brandRepo == nil { + return nil + } + brands, err := u.brandRepo.ListByOwner(ctx, tenantID, ownerUID) + if err != nil { + return err + } + for _, brand := range brands { + if !legacyBrandHasTopicSignals(brand) { + continue + } + existing, err := u.repo.ListByBrand(ctx, tenantID, ownerUID, brand.ID) + if err != nil { + return err + } + if legacyTopicAlreadyMigrated(brand, existing) { + continue + } + topic := &entity.Topic{ + ID: uuid.NewString(), + TenantID: tenantID, + OwnerUID: ownerUID, + BrandID: brand.ID, + TopicName: legacyTopicName(brand), + SeedQuery: strings.TrimSpace(brand.SeedQuery), + Brief: strings.TrimSpace(brand.Brief), + ProductID: strings.TrimSpace(brand.ProductID), + ResearchMap: brand.ResearchMap, + Status: entity.StatusOpen, + } + created, err := u.repo.Create(ctx, topic) + if err != nil { + return err + } + if u.kg != nil { + _ = u.kg.AttachTopicID(ctx, tenantID, ownerUID, brand.ID, created.ID) + } + if u.scanRepo != nil { + _ = u.scanRepo.AttachTopicID(ctx, tenantID, ownerUID, brand.ID, created.ID) + } + _, _ = u.brandRepo.Update(ctx, tenantID, ownerUID, brand.ID, map[string]interface{}{ + "topic_name": "", + "seed_query": "", + "brief": "", + "product_id": "", + "research_map": brandentity.ResearchMap{}, + }) + } + return nil +} + +func (u *topicUseCase) brandDisplayNames(ctx context.Context, tenantID, ownerUID string) (map[string]string, error) { + brands, err := u.brandRepo.ListByOwner(ctx, tenantID, ownerUID) + if err != nil { + return nil, err + } + out := make(map[string]string, len(brands)) + for _, brand := range brands { + out[brand.ID] = brand.DisplayName + } + return out, nil +} + +func legacyBrandHasTopicSignals(brand *brandentity.Brand) bool { + if brand == nil { + return false + } + return strings.TrimSpace(brand.TopicName) != "" || + strings.TrimSpace(brand.SeedQuery) != "" || + strings.TrimSpace(brand.Brief) != "" || + !brand.ResearchMap.IsEmpty() +} + +func legacyTopicAlreadyMigrated(brand *brandentity.Brand, existing []*entity.Topic) bool { + if brand == nil { + return true + } + legacyName := strings.TrimSpace(brand.TopicName) + legacySeed := strings.TrimSpace(brand.SeedQuery) + legacyBrief := strings.TrimSpace(brand.Brief) + legacyProductID := strings.TrimSpace(brand.ProductID) + for _, item := range existing { + if item == nil { + continue + } + sameFields := strings.TrimSpace(item.TopicName) == legacyName && + strings.TrimSpace(item.SeedQuery) == legacySeed && + strings.TrimSpace(item.Brief) == legacyBrief && + strings.TrimSpace(item.ProductID) == legacyProductID + if sameFields { + return true + } + if legacyName == "" && legacySeed == "" && legacyBrief == "" && + !brand.ResearchMap.IsEmpty() && + reflect.DeepEqual(item.ResearchMap, brand.ResearchMap) { + return true + } + } + return false +} + +func legacyTopicName(brand *brandentity.Brand) string { + if brand == nil { + return "" + } + if name := strings.TrimSpace(brand.TopicName); name != "" { + return name + } + for _, pillar := range brand.ResearchMap.Pillars { + if name := strings.TrimSpace(pillar); name != "" { + return name + } + } + for _, question := range brand.ResearchMap.Questions { + if name := strings.TrimSpace(question); name != "" { + return name + } + } + return strings.TrimSpace(brand.DisplayName) +} + +func brandHasProduct(brand *brandentity.Brand, productID string) bool { + if brand == nil { + return false + } + for _, product := range brand.Products { + if product.ID == productID { + return true + } + } + return false +} + +func requireActor(tenantID, ownerUID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return app.For(code.Brand).InputMissingRequired("tenant_id and uid are required") + } + return nil +} + +func toSummary(item *entity.Topic, brandDisplayName string) domusecase.TopicSummary { + if item == nil { + return domusecase.TopicSummary{} + } + return domusecase.TopicSummary{ + ID: item.ID, + BrandID: item.BrandID, + BrandDisplayName: brandDisplayName, + TopicName: item.TopicName, + SeedQuery: item.SeedQuery, + Brief: item.Brief, + ProductID: item.ProductID, + ResearchMap: item.ResearchMap, + CreateAt: item.CreateAt, + UpdateAt: item.UpdateAt, + } +} + +func patchToMap(patch domusecase.TopicPatch) map[string]interface{} { + out := map[string]interface{}{} + if patch.BrandID != nil { + out["brand_id"] = strings.TrimSpace(*patch.BrandID) + } + if patch.TopicName != nil { + out["topic_name"] = strings.TrimSpace(*patch.TopicName) + } + if patch.SeedQuery != nil { + out["seed_query"] = strings.TrimSpace(*patch.SeedQuery) + } + if patch.Brief != nil { + out["brief"] = strings.TrimSpace(*patch.Brief) + } + if patch.ProductID != nil { + out["product_id"] = strings.TrimSpace(*patch.ProductID) + } + if patch.AudienceSummary != nil { + out["research_map.audience_summary"] = strings.TrimSpace(*patch.AudienceSummary) + } + if patch.ContentGoal != nil { + out["research_map.content_goal"] = strings.TrimSpace(*patch.ContentGoal) + } + if patch.QuestionsSet { + out["research_map.questions"] = cleanStringList(patch.Questions) + } + if patch.PillarsSet { + out["research_map.pillars"] = cleanStringList(patch.Pillars) + } + if patch.ExclusionsSet { + out["research_map.exclusions"] = cleanStringList(patch.Exclusions) + } + if patch.ResearchMap != nil { + out["research_map"] = *patch.ResearchMap + } + if patch.PatrolKeywordsSet { + out["research_map.patrol_keywords"] = libkg.NormalizePatrolKeywordList(patch.PatrolKeywords) + } + return out +} + +func cleanStringList(items []string) []string { + out := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out +} diff --git a/haixun-backend/internal/model/scan_post/domain/entity/post.go b/haixun-backend/internal/model/scan_post/domain/entity/post.go index 1c191e5..b4189af 100644 --- a/haixun-backend/internal/model/scan_post/domain/entity/post.go +++ b/haixun-backend/internal/model/scan_post/domain/entity/post.go @@ -19,6 +19,7 @@ type ScanPost struct { TenantID string `bson:"tenant_id"` OwnerUID string `bson:"owner_uid"` BrandID string `bson:"brand_id"` + TopicID string `bson:"topic_id,omitempty"` LegacyPersonaID string `bson:"persona_id,omitempty"` Flow string `bson:"flow,omitempty"` GraphID string `bson:"graph_id"` @@ -42,6 +43,7 @@ type ScanPost struct { PublishedReplyID string `bson:"published_reply_id,omitempty"` PublishedPermalink string `bson:"published_permalink,omitempty"` OutreachUpdateAt int64 `bson:"outreach_update_at,omitempty"` + PostedAt string `bson:"posted_at,omitempty"` Replies []ScanReply `bson:"replies,omitempty"` CreateAt int64 `bson:"create_at"` } diff --git a/haixun-backend/internal/model/scan_post/domain/repository/repository.go b/haixun-backend/internal/model/scan_post/domain/repository/repository.go index f33b320..58334b4 100644 --- a/haixun-backend/internal/model/scan_post/domain/repository/repository.go +++ b/haixun-backend/internal/model/scan_post/domain/repository/repository.go @@ -8,6 +8,7 @@ import ( type ListFilter struct { BrandID string + TopicID string Priority string ProductFitMin int Recent7dOnly bool @@ -22,12 +23,18 @@ type PersonaListFilter struct { type Repository interface { EnsureIndexes(ctx context.Context) error + ClearForBrandScan(ctx context.Context, tenantID, ownerUID, brandID string) error + UpsertBatchForScan(ctx context.Context, tenantID, ownerUID, brandID string, posts []entity.ScanPost) (int, error) + PruneScanJobPosts(ctx context.Context, tenantID, ownerUID, brandID, scanJobID string, keepPermalinks []string) error ReplaceForScan(ctx context.Context, tenantID, ownerUID, brandID, scanJobID string, posts []entity.ScanPost) error ReplaceForViralScan(ctx context.Context, tenantID, ownerUID, personaID, scanJobID string, posts []entity.ScanPost) error Get(ctx context.Context, tenantID, ownerUID, brandID, postID string) (*entity.ScanPost, error) GetForPersona(ctx context.Context, tenantID, ownerUID, personaID, postID string) (*entity.ScanPost, error) UpdateOutreach(ctx context.Context, tenantID, ownerUID, brandID, postID string, patch entity.OutreachPatch) (*entity.ScanPost, error) + Delete(ctx context.Context, tenantID, ownerUID, brandID, topicID, postID string) error + DeleteMany(ctx context.Context, tenantID, ownerUID, brandID, topicID string, postIDs []string) (int, error) List(ctx context.Context, tenantID, ownerUID string, filter ListFilter) ([]entity.ScanPost, error) ListForPersona(ctx context.Context, tenantID, ownerUID string, filter PersonaListFilter) ([]entity.ScanPost, error) CountByBrand(ctx context.Context, tenantID, ownerUID, brandID string) (int, error) + AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error } diff --git a/haixun-backend/internal/model/scan_post/domain/usecase/usecase.go b/haixun-backend/internal/model/scan_post/domain/usecase/usecase.go index e137469..ed02c24 100644 --- a/haixun-backend/internal/model/scan_post/domain/usecase/usecase.go +++ b/haixun-backend/internal/model/scan_post/domain/usecase/usecase.go @@ -40,6 +40,7 @@ type ScanPostSummary struct { PublishedReplyID string PublishedPermalink string OutreachUpdateAt int64 + PostedAt string Replies []ScanReplySummary CreateAt int64 } @@ -48,6 +49,7 @@ type ListRequest struct { TenantID string OwnerUID string BrandID string + TopicID string Priority string ProductFitMin int Recent7dOnly bool @@ -88,12 +90,26 @@ type UpdateOutreachRequest struct { PublishedPermalink string } +type CheckpointRequest struct { + TenantID string + OwnerUID string + BrandID string + GraphID string + ScanJobID string + Posts []placement.ScanCandidate +} + type UseCase interface { + ClearBrandScan(ctx context.Context, tenantID, ownerUID, brandID string) error + UpsertScanCheckpoint(ctx context.Context, req CheckpointRequest) (int, error) + FinalizeScan(ctx context.Context, req ReplaceRequest) (int, error) ReplaceFromScan(ctx context.Context, req ReplaceRequest) (int, error) ReplaceFromViralScan(ctx context.Context, req ViralReplaceRequest) (int, error) Get(ctx context.Context, tenantID, ownerUID, brandID, postID string) (*ScanPostSummary, error) GetForPersona(ctx context.Context, tenantID, ownerUID, personaID, postID string) (*ScanPostSummary, error) UpdateOutreach(ctx context.Context, req UpdateOutreachRequest) (*ScanPostSummary, error) + Delete(ctx context.Context, tenantID, ownerUID, brandID, topicID, postID string) error + DeleteMany(ctx context.Context, tenantID, ownerUID, brandID, topicID string, postIDs []string) (int, error) List(ctx context.Context, req ListRequest) ([]ScanPostSummary, error) ListForPersona(ctx context.Context, req PersonaListRequest) ([]ScanPostSummary, error) } diff --git a/haixun-backend/internal/model/scan_post/repository/mongo.go b/haixun-backend/internal/model/scan_post/repository/mongo.go index 69fa710..7a6f36a 100644 --- a/haixun-backend/internal/model/scan_post/repository/mongo.go +++ b/haixun-backend/internal/model/scan_post/repository/mongo.go @@ -89,6 +89,91 @@ func (r *mongoRepository) ReplaceForViralScan(ctx context.Context, tenantID, own return err } +func (r *mongoRepository) ClearForBrandScan(ctx context.Context, tenantID, ownerUID, brandID string) error { + if r.collection == nil { + return app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.DeleteMany(ctx, brandOwnerFilter(tenantID, ownerUID, brandID)) + return err +} + +func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, ownerUID, brandID string, posts []entity.ScanPost) (int, error) { + if r.collection == nil { + return 0, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + if len(posts) == 0 { + return 0, nil + } + upserted := 0 + for _, post := range posts { + permalink := strings.TrimSpace(post.Permalink) + if permalink == "" { + continue + } + filter := brandOwnerFilter(tenantID, ownerUID, brandID) + filter["permalink"] = permalink + + var existing entity.ScanPost + err := r.collection.FindOne(ctx, filter).Decode(&existing) + if err == nil { + post.ID = existing.ID + if existing.CreateAt > 0 { + post.CreateAt = existing.CreateAt + } + if strings.TrimSpace(existing.OutreachStatus) != "" && existing.OutreachStatus != entity.OutreachStatusPending { + post.OutreachStatus = existing.OutreachStatus + post.PublishedReplyID = existing.PublishedReplyID + post.PublishedPermalink = existing.PublishedPermalink + post.OutreachUpdateAt = existing.OutreachUpdateAt + } + if strings.TrimSpace(existing.PostedAt) != "" && strings.TrimSpace(post.PostedAt) == "" { + post.PostedAt = existing.PostedAt + } + } + if strings.TrimSpace(post.ID) == "" { + continue + } + if post.CreateAt == 0 { + post.CreateAt = time.Now().UnixNano() + } + + raw, err := bson.Marshal(post) + if err != nil { + return upserted, err + } + var doc bson.M + if err := bson.Unmarshal(raw, &doc); err != nil { + return upserted, err + } + delete(doc, "_id") + + _, err = r.collection.UpdateOne( + ctx, + filter, + bson.M{"$set": doc, "$setOnInsert": bson.M{"_id": post.ID}}, + options.Update().SetUpsert(true), + ) + if err != nil { + return upserted, err + } + upserted++ + } + return upserted, nil +} + +func (r *mongoRepository) PruneScanJobPosts(ctx context.Context, tenantID, ownerUID, brandID, scanJobID string, keepPermalinks []string) error { + if r.collection == nil { + return app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + filter := brandOwnerFilter(tenantID, ownerUID, brandID) + filter["scan_job_id"] = strings.TrimSpace(scanJobID) + if len(keepPermalinks) > 0 { + filter["permalink"] = bson.M{"$nin": keepPermalinks} + } + _, err := r.collection.DeleteMany(ctx, filter) + return err +} + func (r *mongoRepository) ReplaceForScan(ctx context.Context, tenantID, ownerUID, brandID, scanJobID string, posts []entity.ScanPost) error { if r.collection == nil { return app.For(code.Brand).DBUnavailable("Mongo is not configured") @@ -162,6 +247,55 @@ func (r *mongoRepository) UpdateOutreach( return &out, nil } +func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, brandID, topicID, postID string) error { + if r.collection == nil { + return app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + postID = strings.TrimSpace(postID) + if postID == "" { + return app.For(code.Brand).InputMissingRequired("scan post id is required") + } + filter := topicScopeFilter(tenantID, ownerUID, topicID, brandID) + filter["_id"] = postID + result, err := r.collection.DeleteOne(ctx, filter) + if err != nil { + return err + } + if result.DeletedCount == 0 { + return nil + } + return nil +} + +func (r *mongoRepository) DeleteMany(ctx context.Context, tenantID, ownerUID, brandID, topicID string, postIDs []string) (int, error) { + if r.collection == nil { + return 0, app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + ids := make([]string, 0, len(postIDs)) + seen := map[string]struct{}{} + for _, postID := range postIDs { + id := strings.TrimSpace(postID) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return 0, nil + } + filter := topicScopeFilter(tenantID, ownerUID, topicID, brandID) + filter["_id"] = bson.M{"$in": ids} + result, err := r.collection.DeleteMany(ctx, filter) + if err != nil { + return 0, err + } + return int(result.DeletedCount), nil +} + func (r *mongoRepository) GetForPersona(ctx context.Context, tenantID, ownerUID, personaID, postID string) (*entity.ScanPost, error) { if r.collection == nil { return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured") @@ -209,11 +343,49 @@ func (r *mongoRepository) ListForPersona(ctx context.Context, tenantID, ownerUID return out, nil } +func topicScopeFilter(tenantID, ownerUID, topicID, brandID string) bson.M { + topicID = strings.TrimSpace(topicID) + brandID = strings.TrimSpace(brandID) + filter := bson.M{ + "tenant_id": tenantID, + "owner_uid": ownerUID, + } + if topicID != "" { + legacy := bson.M{"topic_id": bson.M{"$in": []interface{}{nil, ""}}} + if brandID != "" { + for k, v := range libmongo.BrandScopeFilter(brandID) { + legacy[k] = v + } + } + filter["$or"] = []bson.M{ + {"topic_id": topicID}, + legacy, + } + return filter + } + for k, v := range libmongo.BrandScopeFilter(brandID) { + filter[k] = v + } + return filter +} + +func (r *mongoRepository) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error { + if r.collection == nil { + return app.For(code.Brand).DBUnavailable("Mongo is not configured") + } + _, err := r.collection.UpdateMany( + ctx, + brandOwnerFilter(tenantID, ownerUID, brandID), + bson.M{"$set": bson.M{"topic_id": strings.TrimSpace(topicID)}}, + ) + return err +} + func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, filter domrepo.ListFilter) ([]entity.ScanPost, error) { if r.collection == nil { return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured") } - query := brandOwnerFilter(tenantID, ownerUID, filter.BrandID) + query := topicScopeFilter(tenantID, ownerUID, filter.TopicID, filter.BrandID) if strings.TrimSpace(filter.Priority) != "" { query["priority"] = strings.TrimSpace(filter.Priority) } diff --git a/haixun-backend/internal/model/scan_post/usecase/usecase.go b/haixun-backend/internal/model/scan_post/usecase/usecase.go index 3c4da46..525a4b9 100644 --- a/haixun-backend/internal/model/scan_post/usecase/usecase.go +++ b/haixun-backend/internal/model/scan_post/usecase/usecase.go @@ -59,21 +59,68 @@ func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domuseca return len(entities), nil } +func (u *scanPostUseCase) ClearBrandScan(ctx context.Context, tenantID, ownerUID, brandID string) error { + if err := requireActor(tenantID, ownerUID, brandID); err != nil { + return err + } + return u.repo.ClearForBrandScan(ctx, tenantID, ownerUID, brandID) +} + +func (u *scanPostUseCase) UpsertScanCheckpoint(ctx context.Context, req domusecase.CheckpointRequest) (int, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { + return 0, err + } + entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.GraphID, req.ScanJobID, req.Posts) + return u.repo.UpsertBatchForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, entities) +} + +func (u *scanPostUseCase) FinalizeScan(ctx context.Context, req domusecase.ReplaceRequest) (int, error) { + if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { + return 0, err + } + entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.GraphID, req.ScanJobID, req.Posts) + count, err := u.repo.UpsertBatchForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, entities) + if err != nil { + return 0, err + } + keep := make([]string, 0, len(entities)) + for _, item := range entities { + if strings.TrimSpace(item.Permalink) != "" { + keep = append(keep, item.Permalink) + } + } + if err := u.repo.PruneScanJobPosts(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.ScanJobID, keep); err != nil { + return count, err + } + return count, nil +} + func (u *scanPostUseCase) ReplaceFromScan(ctx context.Context, req domusecase.ReplaceRequest) (int, error) { if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { return 0, err } + entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.GraphID, req.ScanJobID, req.Posts) + if err := u.repo.ReplaceForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.ScanJobID, entities); err != nil { + return 0, err + } + return len(entities), nil +} + +func placementCandidatesToEntities(tenantID, ownerUID, brandID, graphID, scanJobID string, posts []placement.ScanCandidate) []entity.ScanPost { now := clock.NowUnixNano() - entities := make([]entity.ScanPost, 0, len(req.Posts)) - for _, item := range req.Posts { + entities := make([]entity.ScanPost, 0, len(posts)) + for _, item := range posts { + if strings.TrimSpace(item.Permalink) == "" { + continue + } entities = append(entities, entity.ScanPost{ ID: uuid.NewString(), - TenantID: req.TenantID, - OwnerUID: req.OwnerUID, - BrandID: req.BrandID, + TenantID: tenantID, + OwnerUID: ownerUID, + BrandID: brandID, Flow: entity.FlowPlacement, - GraphID: req.GraphID, - ScanJobID: req.ScanJobID, + GraphID: graphID, + ScanJobID: scanJobID, GraphNodeID: item.GraphNodeID, SearchTag: item.SearchTag, QueryDimension: string(item.QueryDimension), @@ -87,14 +134,12 @@ func (u *scanPostUseCase) ReplaceFromScan(ctx context.Context, req domusecase.Re SolvedByProduct: item.SolvedByProduct, Source: string(item.Source), OutreachStatus: entity.OutreachStatusPending, + PostedAt: strings.TrimSpace(item.PostedAt), Replies: toReplyEntities(item.Replies), CreateAt: now, }) } - if err := u.repo.ReplaceForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.ScanJobID, entities); err != nil { - return 0, err - } - return len(entities), nil + return entities } func (u *scanPostUseCase) Get(ctx context.Context, tenantID, ownerUID, brandID, postID string) (*domusecase.ScanPostSummary, error) { @@ -137,6 +182,40 @@ func (u *scanPostUseCase) UpdateOutreach(ctx context.Context, req domusecase.Upd return &summary, nil } +func (u *scanPostUseCase) Delete(ctx context.Context, tenantID, ownerUID, brandID, topicID, postID string) error { + if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil { + return err + } + postID = strings.TrimSpace(postID) + if postID == "" { + return app.For(code.Brand).InputMissingRequired("scan post id is required") + } + return u.repo.Delete(ctx, tenantID, ownerUID, brandID, topicID, postID) +} + +func (u *scanPostUseCase) DeleteMany(ctx context.Context, tenantID, ownerUID, brandID, topicID string, postIDs []string) (int, error) { + if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil { + return 0, err + } + ids := make([]string, 0, len(postIDs)) + seen := map[string]struct{}{} + for _, postID := range postIDs { + id := strings.TrimSpace(postID) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return 0, app.For(code.Brand).InputMissingRequired("post_ids is required") + } + return u.repo.DeleteMany(ctx, tenantID, ownerUID, brandID, topicID, ids) +} + func (u *scanPostUseCase) GetForPersona(ctx context.Context, tenantID, ownerUID, personaID, postID string) (*domusecase.ScanPostSummary, error) { if err := requireViralActor(tenantID, ownerUID, personaID); err != nil { return nil, err @@ -173,11 +252,12 @@ func (u *scanPostUseCase) ListForPersona(ctx context.Context, req domusecase.Per } func (u *scanPostUseCase) List(ctx context.Context, req domusecase.ListRequest) ([]domusecase.ScanPostSummary, error) { - if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil { + if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil { return nil, err } items, err := u.repo.List(ctx, req.TenantID, req.OwnerUID, domrepo.ListFilter{ BrandID: req.BrandID, + TopicID: req.TopicID, Priority: req.Priority, ProductFitMin: req.ProductFitMin, Recent7dOnly: req.Recent7dOnly, @@ -203,6 +283,16 @@ func requireActor(tenantID, ownerUID, brandID string) error { return nil } +func requireListActor(tenantID, ownerUID, brandID, topicID string) error { + if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { + return errMissingActor() + } + if strings.TrimSpace(topicID) == "" && strings.TrimSpace(brandID) == "" { + return errMissingBrand() + } + return nil +} + func requireViralActor(tenantID, ownerUID, personaID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return errMissingActor() @@ -279,6 +369,7 @@ func toSummary(item entity.ScanPost) domusecase.ScanPostSummary { PublishedReplyID: item.PublishedReplyID, PublishedPermalink: item.PublishedPermalink, OutreachUpdateAt: item.OutreachUpdateAt, + PostedAt: item.PostedAt, Replies: toReplySummaries(item.Replies), CreateAt: item.CreateAt, } diff --git a/haixun-backend/internal/model/threads_account/usecase/placement_context.go b/haixun-backend/internal/model/threads_account/usecase/placement_context.go index 5cab6c6..6492b5a 100644 --- a/haixun-backend/internal/model/threads_account/usecase/placement_context.go +++ b/haixun-backend/internal/model/threads_account/usecase/placement_context.go @@ -30,7 +30,7 @@ func (u *threadsAccountUseCase) ResolveMemberPlacementContext( if err == nil { prefs = loaded } - apiConnected, browserConnected, _ = u.connectionFlags(ctx, account.ID) + browserConnected, apiConnected, _ = u.connectionFlags(ctx, account.ID) if apiConnected { if secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID); err == nil && secrets != nil { apiToken = strings.TrimSpace(secrets.APIAccessToken) diff --git a/haixun-backend/internal/model/threads_account/usecase/placement_context_test.go b/haixun-backend/internal/model/threads_account/usecase/placement_context_test.go new file mode 100644 index 0000000..3c04b5d --- /dev/null +++ b/haixun-backend/internal/model/threads_account/usecase/placement_context_test.go @@ -0,0 +1,18 @@ +package usecase + +import "testing" + +func TestConnectionFlagsAssignmentOrder(t *testing.T) { + // connectionFlags returns (browserConnected, apiConnected). + browserFromRepo, apiFromRepo := true, false + + var browserConnected, apiConnected bool + browserConnected, apiConnected = browserFromRepo, apiFromRepo + + if !browserConnected { + t.Fatal("browser flag should be true") + } + if apiConnected { + t.Fatal("api flag should be false") + } +} diff --git a/haixun-backend/internal/svc/service_context.go b/haixun-backend/internal/svc/service_context.go index ea84e75..691e59a 100644 --- a/haixun-backend/internal/svc/service_context.go +++ b/haixun-backend/internal/svc/service_context.go @@ -43,6 +43,9 @@ import ( personarepo "haixun-backend/internal/model/persona/repository" personausecase "haixun-backend/internal/model/persona/usecase" placementusecase "haixun-backend/internal/model/placement/usecase" + placementtopicdomain "haixun-backend/internal/model/placement_topic/domain/usecase" + placementtopicrepo "haixun-backend/internal/model/placement_topic/repository" + placementtopicusecase "haixun-backend/internal/model/placement_topic/usecase" scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase" scanpostrepo "haixun-backend/internal/model/scan_post/repository" scanpostusecase "haixun-backend/internal/model/scan_post/usecase" @@ -71,6 +74,7 @@ type ServiceContext struct { Permission permissiondomain.UseCase Persona personadomain.UseCase Brand branddomain.UseCase + PlacementTopic placementtopicdomain.UseCase KnowledgeGraph kgdomain.UseCase Placement placementusecase.UseCase ScanPost scanpostdomain.UseCase @@ -204,6 +208,16 @@ func NewServiceContext(c config.Config) *ServiceContext { panic(err) } brandUseCase := brandusecase.NewUseCase(brandRepository) + placementTopicRepository := placementtopicrepo.NewMongoRepository(mongoClient.Database()) + if err := placementTopicRepository.EnsureIndexes(ctx); err != nil { + panic(err) + } + placementTopicUseCase := placementtopicusecase.NewUseCase( + placementTopicRepository, + brandRepository, + knowledgeGraphUseCase, + scanPostRepository, + ) threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database()) threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database()) if err := threadsAccountRepository.EnsureIndexes(ctx); err != nil { @@ -235,6 +249,7 @@ func NewServiceContext(c config.Config) *ServiceContext { Permission: permissionUseCase, Persona: personaUseCase, Brand: brandUseCase, + PlacementTopic: placementTopicUseCase, KnowledgeGraph: knowledgeGraphUseCase, Placement: placementUseCase, ScanPost: scanPostUseCase, @@ -260,6 +275,7 @@ func NewServiceContext(c config.Config) *ServiceContext { jobworker.RegisterExpandGraphHandler(runner, jobworker.ExpandGraphDeps{ Jobs: jobUseCase, Brand: brandUseCase, + PlacementTopic: placementTopicUseCase, KnowledgeGraph: knowledgeGraphUseCase, ThreadsAccount: threadsAccountUseCase, Placement: placementUseCase, diff --git a/haixun-backend/internal/types/types.go b/haixun-backend/internal/types/types.go index 34ea3a1..8860e5e 100644 --- a/haixun-backend/internal/types/types.go +++ b/haixun-backend/internal/types/types.go @@ -96,24 +96,54 @@ type AuthTokenData struct { TokenType string `json:"token_type"` } +type BatchDeletePlacementTopicScanPostsData struct { + DeletedCount int `json:"deleted_count"` +} + +type BatchDeletePlacementTopicScanPostsHandlerReq struct { + PlacementTopicPath + BatchDeletePlacementTopicScanPostsReq +} + +type BatchDeletePlacementTopicScanPostsReq struct { + PostIDs []string `json:"post_ids" validate:"required"` +} + type BrandData struct { - ID string `json:"id"` - DisplayName string `json:"display_name,omitempty"` - SeedQuery string `json:"seed_query,omitempty"` - Brief string `json:"brief,omitempty"` - ProductBrief string `json:"product_brief,omitempty"` - ProductContext string `json:"product_context,omitempty"` - TargetAudience string `json:"target_audience,omitempty"` - Goals string `json:"goals,omitempty"` - ResearchMap ResearchMapData `json:"research_map,omitempty"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + TopicName string `json:"topic_name,omitempty"` + SeedQuery string `json:"seed_query,omitempty"` + Brief string `json:"brief,omitempty"` + ProductBrief string `json:"product_brief,omitempty"` + ProductContext string `json:"product_context,omitempty"` + ProductID string `json:"product_id,omitempty"` + Products []BrandProductData `json:"products,omitempty"` + TargetAudience string `json:"target_audience,omitempty"` + Goals string `json:"goals,omitempty"` + ResearchMap ResearchMapData `json:"research_map,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` } type BrandPath struct { ID string `path:"id" validate:"required"` } +type BrandProductData struct { + ID string `json:"id"` + Label string `json:"label"` + ProductContext string `json:"product_context"` + MatchTags []string `json:"match_tags,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + +type BrandProductPath struct { + ID string `path:"id" validate:"required"` + ProductID string `path:"productId" validate:"required"` +} + type BrandScanScheduleData struct { ID string `json:"id,omitempty"` BrandID string `json:"brand_id"` @@ -124,6 +154,13 @@ type BrandScanScheduleData struct { LastRunAt int64 `json:"last_run_at,omitempty"` } +type BraveSourceData struct { + Query string `json:"query,omitempty"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + Snippet string `json:"snippet,omitempty"` +} + type CancelJobReq struct { ID string `path:"id" validate:"required"` // job run id Reason string `json:"reason,optional"` // cancel reason @@ -179,6 +216,17 @@ type CopyResearchMapData struct { BenchmarkNotes string `json:"benchmark_notes,omitempty"` } +type CreateBrandProductHandlerReq struct { + BrandPath + CreateBrandProductReq +} + +type CreateBrandProductReq struct { + Label string `json:"label" validate:"required"` + ProductContext string `json:"product_context" validate:"required"` + MatchTags []string `json:"match_tags,optional"` +} + type CreateBrandReq struct { DisplayName string `json:"display_name,optional"` } @@ -204,11 +252,28 @@ type CreatePersonaReq struct { DisplayName string `json:"display_name,optional"` } +type CreatePlacementTopicHandlerReq struct { + CreatePlacementTopicReq +} + +type CreatePlacementTopicReq struct { + BrandID string `json:"brand_id" validate:"required"` + TopicName string `json:"topic_name" validate:"required"` + SeedQuery string `json:"seed_query" validate:"required"` + Brief string `json:"brief" validate:"required"` + ProductID string `json:"product_id,optional"` +} + type CreateThreadsAccountReq struct { DisplayName string `json:"display_name,optional"` Activate *bool `json:"activate,optional"` } +type DeletePlacementTopicScanPostHandlerReq struct { + PlacementTopicPath + PostID string `path:"postId" validate:"required"` +} + type ErrorDetail struct { BizCode string `json:"biz_code,optional"` Scope int64 `json:"scope,optional"` @@ -228,8 +293,15 @@ type ExpandKnowledgeGraphHandlerReq struct { } type ExpandKnowledgeGraphReq struct { - SeedQuery string `json:"seed_query" validate:"required"` - Supplemental bool `json:"supplemental,optional"` + SeedQuery string `json:"seed_query" validate:"required"` + Supplemental bool `json:"supplemental,optional"` + RegenerateMap bool `json:"regenerate_map,optional"` + ExpandStrategy string `json:"expand_strategy,optional"` // brave | llm | hybrid +} + +type ExpandPlacementTopicGraphHandlerReq struct { + PlacementTopicPath + ExpandKnowledgeGraphReq } type GenerateContentMatrixHandlerReq struct { @@ -259,6 +331,7 @@ type GenerateOutreachDraftsReq struct { ScanPostID string `json:"scan_post_id" validate:"required"` Count int `json:"count,optional"` VoicePersonaID string `json:"voice_persona_id,optional"` + ProductID string `json:"product_id,optional"` } type GeneratePersonaCopyDraftData struct { @@ -275,6 +348,16 @@ type GeneratePersonaCopyDraftReq struct { ScanPostID string `json:"scan_post_id" validate:"required"` } +type GeneratePlacementTopicContentMatrixHandlerReq struct { + PlacementTopicPath + GenerateContentMatrixReq +} + +type GeneratePlacementTopicOutreachDraftsHandlerReq struct { + PlacementTopicPath + GenerateOutreachDraftsReq +} + type HealthData struct { Pong string `json:"pong"` } @@ -431,15 +514,17 @@ type JobTemplateStepData struct { } type KnowledgeGraphData struct { - ID string `json:"id"` - BrandID string `json:"brand_id"` - Seed string `json:"seed"` - Nodes []KnowledgeGraphNodeData `json:"nodes"` - Edges []KnowledgeGraphEdgeData `json:"edges"` - PainTagCount int `json:"pain_tag_count"` - GeneratedAt int64 `json:"generated_at"` - CreateAt int64 `json:"create_at"` - UpdateAt int64 `json:"update_at"` + ID string `json:"id"` + BrandID string `json:"brand_id"` + Seed string `json:"seed"` + Nodes []KnowledgeGraphNodeData `json:"nodes"` + Edges []KnowledgeGraphEdgeData `json:"edges"` + BraveSources []BraveSourceData `json:"brave_sources,omitempty"` + ExpandStrategy string `json:"expand_strategy,omitempty"` + PainTagCount int `json:"pain_tag_count"` + GeneratedAt int64 `json:"generated_at"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` } type KnowledgeGraphEdgeData struct { @@ -448,23 +533,36 @@ type KnowledgeGraphEdgeData struct { Relation string `json:"relation"` } +type KnowledgeGraphEvidenceData struct { + URL string `json:"url,omitempty"` + Snippet string `json:"snippet,omitempty"` + Query string `json:"query,omitempty"` +} + type KnowledgeGraphNodeData struct { - ID string `json:"id"` - Label string `json:"label"` - NodeKind string `json:"node_kind"` - Type string `json:"type"` - Layer int `json:"layer"` - Relation string `json:"relation,omitempty"` - PlacementValue string `json:"placement_value,omitempty"` - ProductFitScore int `json:"product_fit_score"` - SelectedForScan bool `json:"selected_for_scan"` - RelevanceTags []string `json:"relevance_tags"` - RecencyTags []string `json:"recency_tags"` + ID string `json:"id"` + Label string `json:"label"` + NodeKind string `json:"node_kind"` + Type string `json:"type"` + Layer int `json:"layer"` + Relation string `json:"relation,omitempty"` + PlacementValue string `json:"placement_value,omitempty"` + ProductFitScore int `json:"product_fit_score"` + SelectedForScan bool `json:"selected_for_scan"` + RelevanceTags []string `json:"relevance_tags"` + RecencyTags []string `json:"recency_tags"` + Evidence []KnowledgeGraphEvidenceData `json:"evidence,omitempty"` } type KnowledgeGraphNodeUpdate struct { - NodeID string `json:"node_id" validate:"required"` - SelectedForScan bool `json:"selected_for_scan"` + NodeID string `json:"node_id" validate:"required"` + SelectedForScan *bool `json:"selected_for_scan,optional"` + RelevanceTags []string `json:"relevance_tags,optional"` + RecencyTags []string `json:"recency_tags,optional"` +} + +type ListBrandProductsData struct { + List []BrandProductData `json:"list"` } type ListBrandScanPostsData struct { @@ -530,6 +628,15 @@ type ListPersonasData struct { List []PersonaData `json:"list"` } +type ListPlacementTopicScanPostsHandlerReq struct { + PlacementTopicPath + ListBrandScanPostsReq +} + +type ListPlacementTopicsData struct { + List []PlacementTopicData `json:"list"` +} + type ListThreadsAccountsData struct { List []ThreadsAccountData `json:"list"` ActiveAccountID string `json:"active_account_id"` @@ -576,6 +683,7 @@ type MemberPlacementSettingsData struct { BraveAPIKeyConfigured bool `json:"brave_api_key_configured"` BraveCountry string `json:"brave_country"` BraveSearchLang string `json:"brave_search_lang"` + ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid } type OutreachDraftItemData struct { @@ -600,6 +708,17 @@ type PatchKnowledgeGraphNodesReq struct { Updates []KnowledgeGraphNodeUpdate `json:"updates" validate:"required"` } +type PatchPlacementTopicGraphNodesHandlerReq struct { + PlacementTopicPath + PatchKnowledgeGraphNodesReq +} + +type PatchPlacementTopicScanPostOutreachHandlerReq struct { + PlacementTopicPath + PostID string `path:"postId"` + PatchScanPostOutreachReq +} + type PatchScanPostOutreachHandlerReq struct { BrandPath PostID string `path:"postId"` @@ -649,6 +768,23 @@ type PersonaPath struct { ID string `path:"id" validate:"required"` } +type PlacementTopicData struct { + ID string `json:"id"` + BrandID string `json:"brand_id"` + BrandDisplayName string `json:"brand_display_name,omitempty"` + TopicName string `json:"topic_name,omitempty"` + SeedQuery string `json:"seed_query,omitempty"` + Brief string `json:"brief,omitempty"` + ProductID string `json:"product_id,omitempty"` + ResearchMap ResearchMapData `json:"research_map,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + +type PlacementTopicPath struct { + ID string `path:"id" validate:"required"` +} + type PublishOutreachDraftData struct { ScanPostID string `json:"scan_post_id"` ReplyID string `json:"reply_id"` @@ -669,35 +805,52 @@ type PublishOutreachDraftReq struct { Confirm bool `json:"confirm"` } +type PublishPlacementTopicOutreachDraftHandlerReq struct { + PlacementTopicPath + PublishOutreachDraftReq +} + +type ResearchItemData struct { + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + Snippet string `json:"snippet,omitempty"` + Query string `json:"query,omitempty"` +} + type ResearchMapData struct { - AudienceSummary string `json:"audience_summary,omitempty"` - ContentGoal string `json:"content_goal,omitempty"` - Questions []string `json:"questions,omitempty"` - Pillars []string `json:"pillars,omitempty"` - Exclusions []string `json:"exclusions,omitempty"` + 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"` + ResearchItems []ResearchItemData `json:"research_items,omitempty"` + ExpandStrategy string `json:"expand_strategy,omitempty"` + PatrolKeywords []string `json:"patrol_keywords,omitempty"` } type ScanPostData struct { - ID string `json:"id"` - GraphNodeID string `json:"graph_node_id"` - SearchTag string `json:"search_tag"` - QueryDimension string `json:"query_dimension"` - ExternalID string `json:"external_id"` - Permalink string `json:"permalink"` - Author string `json:"author"` - Text string `json:"text"` - Priority string `json:"priority"` - PlacementScore int `json:"placement_score"` - ProductFitScore int `json:"product_fit_score"` - SolvedByProduct bool `json:"solved_by_product"` - Source string `json:"source"` - ScanJobID string `json:"scan_job_id"` - OutreachStatus string `json:"outreach_status,omitempty"` - PublishedReplyID string `json:"published_reply_id,omitempty"` - PublishedPermalink string `json:"published_permalink,omitempty"` - OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"` - Replies []ScanReplyData `json:"replies,omitempty"` - CreateAt int64 `json:"create_at"` + ID string `json:"id"` + GraphNodeID string `json:"graph_node_id"` + SearchTag string `json:"search_tag"` + QueryDimension string `json:"query_dimension"` + ExternalID string `json:"external_id"` + Permalink string `json:"permalink"` + Author string `json:"author"` + Text string `json:"text"` + Priority string `json:"priority"` + PlacementScore int `json:"placement_score"` + ProductFitScore int `json:"product_fit_score"` + SolvedByProduct bool `json:"solved_by_product"` + Source string `json:"source"` + ScanJobID string `json:"scan_job_id"` + OutreachStatus string `json:"outreach_status,omitempty"` + PublishedReplyID string `json:"published_reply_id,omitempty"` + PublishedPermalink string `json:"published_permalink,omitempty"` + OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"` + PostedAt string `json:"posted_at,omitempty"` + Replies []ScanReplyData `json:"replies,omitempty"` + LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"` + CreateAt int64 `json:"create_at"` } type ScanReplyData struct { @@ -758,9 +911,10 @@ type StartBrandScanJobHandlerReq struct { } type StartBrandScanJobReq struct { - GraphID string `json:"graph_id,optional"` - NodeIDs []string `json:"node_ids,optional"` - DualTrack bool `json:"dual_track,optional"` + GraphID string `json:"graph_id,optional"` + NodeIDs []string `json:"node_ids,optional"` + DualTrack bool `json:"dual_track,optional"` + PatrolMode bool `json:"patrol_mode,optional"` } type StartPersonaStyleAnalysisData struct { @@ -793,6 +947,11 @@ type StartPersonaViralScanJobReq struct { Keywords []string `json:"keywords,optional"` } +type StartPlacementTopicScanJobHandlerReq struct { + PlacementTopicPath + StartBrandScanJobReq +} + type Status struct { Code int64 `json:"code"` Message string `json:"message"` @@ -865,14 +1024,33 @@ type UpdateBrandHandlerReq struct { UpdateBrandReq } +type UpdateBrandProductHandlerReq struct { + BrandProductPath + UpdateBrandProductReq +} + +type UpdateBrandProductReq struct { + Label *string `json:"label,optional"` + ProductContext *string `json:"product_context,optional"` + MatchTags []string `json:"match_tags,optional"` +} + type UpdateBrandReq struct { - DisplayName *string `json:"display_name,optional"` - SeedQuery *string `json:"seed_query,optional"` - Brief *string `json:"brief,optional"` - ProductBrief *string `json:"product_brief,optional"` - ProductContext *string `json:"product_context,optional"` - TargetAudience *string `json:"target_audience,optional"` - Goals *string `json:"goals,optional"` + DisplayName *string `json:"display_name,optional"` + TopicName *string `json:"topic_name,optional"` + SeedQuery *string `json:"seed_query,optional"` + Brief *string `json:"brief,optional"` + ProductBrief *string `json:"product_brief,optional"` + ProductContext *string `json:"product_context,optional"` + ProductID *string `json:"product_id,optional"` + TargetAudience *string `json:"target_audience,optional"` + Goals *string `json:"goals,optional"` + AudienceSummary *string `json:"audience_summary,optional"` + ContentGoal *string `json:"content_goal,optional"` + Questions []string `json:"questions,optional"` + Pillars []string `json:"pillars,optional"` + Exclusions []string `json:"exclusions,optional"` + PatrolKeywords []string `json:"patrol_keywords,optional"` } type UpdateJobScheduleReq struct { @@ -895,6 +1073,7 @@ type UpdateMemberPlacementSettingsReq struct { BraveAPIKey *string `json:"brave_api_key,optional"` BraveCountry *string `json:"brave_country,optional"` BraveSearchLang *string `json:"brave_search_lang,optional"` + ExpandStrategy *string `json:"expand_strategy,optional"` } type UpdatePersonaHandlerReq struct { @@ -909,6 +1088,25 @@ type UpdatePersonaReq struct { StyleBenchmark *string `json:"style_benchmark,optional"` } +type UpdatePlacementTopicHandlerReq struct { + PlacementTopicPath + UpdatePlacementTopicReq +} + +type UpdatePlacementTopicReq struct { + BrandID *string `json:"brand_id,optional"` + TopicName *string `json:"topic_name,optional"` + SeedQuery *string `json:"seed_query,optional"` + Brief *string `json:"brief,optional"` + ProductID *string `json:"product_id,optional"` + AudienceSummary *string `json:"audience_summary,optional"` + ContentGoal *string `json:"content_goal,optional"` + Questions []string `json:"questions,optional"` + Pillars []string `json:"pillars,optional"` + Exclusions []string `json:"exclusions,optional"` + PatrolKeywords []string `json:"patrol_keywords,optional"` +} + type UpdateThreadsAccountAiSettingsHandlerReq struct { ThreadsAccountPath UpdateThreadsAccountAiSettingsReq @@ -974,6 +1172,11 @@ type UpsertJobTemplateReq struct { Steps []JobTemplateStepData `json:"steps" validate:"required,min=1,dive"` // steps } +type UpsertPlacementTopicScanScheduleHandlerReq struct { + PlacementTopicPath + UpsertBrandScanScheduleReq +} + type ViralScanPostData struct { ID string `json:"id"` SearchTag string `json:"search_tag"` diff --git a/haixun-backend/internal/worker/job/expand_graph.go b/haixun-backend/internal/worker/job/expand_graph.go index bdd35d3..e7c8308 100644 --- a/haixun-backend/internal/worker/job/expand_graph.go +++ b/haixun-backend/internal/worker/job/expand_graph.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "sync" libbrave "haixun-backend/internal/library/brave" "haixun-backend/internal/library/clock" @@ -20,12 +21,14 @@ import ( jobdom "haixun-backend/internal/model/job/domain/usecase" kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase" placementusecase "haixun-backend/internal/model/placement/usecase" + topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase" threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase" ) type ExpandGraphDeps struct { Jobs jobdom.UseCase Brand branddomain.UseCase + PlacementTopic topicdomain.UseCase KnowledgeGraph kgusecase.UseCase ThreadsAccount threadsaccountdomain.UseCase Placement placementusecase.UseCase @@ -53,21 +56,25 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) payload := step.Run.Payload tenantID := stringField(payload, "tenant_id") ownerUID := stringField(payload, "owner_uid") - brandID := brandIDFromPayload(payload) seed := stringField(payload, "seed_query") supplemental := boolField(payload, "supplemental") - if tenantID == "" || ownerUID == "" || brandID == "" { - return fmt.Errorf("expand-graph payload missing tenant_id, owner_uid, or brand_id") + if tenantID == "" || ownerUID == "" { + return fmt.Errorf("expand-graph payload missing tenant_id or owner_uid") } if seed == "" { return fmt.Errorf("expand-graph payload missing seed_query") } - brand, err := deps.Brand.Get(ctx, tenantID, ownerUID, brandID) + scope, err := resolvePlacementScope(ctx, deps.Brand, deps.PlacementTopic, tenantID, ownerUID, payload) if err != nil { return err } + brand := scope.Brand + brandID := scope.CatalogBrand + if brandID == "" { + return fmt.Errorf("expand-graph payload missing brand_id or topic_id") + } productBrief := strings.TrimSpace(brand.ProductBrief) if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" { @@ -105,7 +112,56 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) } bootstrap := boolField(payload, "bootstrap") - if bootstrap || brand.ResearchMap.IsEmpty() { + regenerateMap := boolField(payload, "regenerate_map") + expandStrategy := libkg.ParseExpandStrategy(stringField(payload, "expand_strategy")) + needResearchMap := bootstrap || regenerateMap || brand.ResearchMap.IsEmpty() + prefetchedBrave := []libkg.BraveSource{} + var prefetchQueries []string + + if needResearchMap && expandStrategy.RequiresBrave() { + updateProgress("平行產生研究地圖與蒐集參考資料…", 5) + var mapErr error + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + mapErr = ensureResearchMap(ctx, step, deps, brand, productBrief, providerID, credential, updateProgress) + }() + + go func() { + defer wg.Done() + prefetchPlan := kgPlanInput(brand, seed, productBrief, nil, false, expandStrategy) + prefetchQueries = libkg.PlanBootstrapQueries(prefetchPlan) + if len(prefetchQueries) == 0 { + return + } + sources, err := runBraveKnowledgeExpand(ctx, braveClient, memberCtx, prefetchQueries, expandStrategy, func(i, total int) { + pct := 8 + ((i + 1) * 12 / max(total, 1)) + updateProgress(fmt.Sprintf("預先蒐集參考資料 %d/%d…", i+1, total), pct) + }, func() error { + cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID) + if cancelled { + return errJobCancelled + } + return ctx.Err() + }) + if err == nil { + prefetchedBrave = sources + return + } + prefetchQueries = nil + }() + + wg.Wait() + if mapErr != nil { + return mapErr + } + brand, err = deps.Brand.Get(ctx, tenantID, ownerUID, brandID) + if err != nil { + return err + } + } else if needResearchMap { updateProgress("產生研究地圖…", 5) if err := ensureResearchMap(ctx, step, deps, brand, productBrief, providerID, credential, updateProgress); err != nil { return err @@ -116,58 +172,84 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) } } - updateProgress("規劃 Brave 查詢…", 10) - var existing *kgusecase.GraphSummary if supplemental { existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID) } - l1Labels := []string{} - if existing != nil { - l1Labels = libkg.L1LabelsFromNodes(existing.Nodes) - } - queries := libkg.PlanQueries(libkg.PlanInput{ - Seed: seed, - TargetAudience: brand.TargetAudience, - ProductBrief: productBrief, - L1Labels: l1Labels, - Supplemental: supplemental, - }) + braveSources := []libkg.BraveSource{} + var systemPrompt string + var userPrompt string - updateProgress(fmt.Sprintf("Brave 知識擴展(%d 查詢)…", len(queries)), 25) - - braveSources, err := runBraveKnowledgeExpand(ctx, braveClient, memberCtx, queries, func(i, total int) { - pct := 25 + ((i + 1) * 30 / max(total, 1)) - updateProgress(fmt.Sprintf("Brave 查詢進行中 %d/%d…", i+1, total), pct) - }, func() error { - cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID) - if cancelled { - return errJobCancelled + switch expandStrategy { + case libkg.ExpandStrategyLLM: + updateProgress("整理延伸知識…", 20) + systemPrompt, err = libprompt.KnowledgeGraphLLMSystem() + if err != nil { + return app.For(code.AI).SysInternal("knowledge graph llm prompt load failed") } - return ctx.Err() - }) - if err != nil { - return err - } + topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief) + kgVars := topicCtx.PromptLines() + kgVars["seed"] = seed + kgVars["product_brief_line"] = libkg.OptionalPromptLine("產品簡述", productBrief) + kgVars["target_audience_line"] = libkg.OptionalPromptLine("目標受眾", brand.TargetAudience) + kgVars["persona_line"] = libkg.OptionalPromptLine("主題目標", brand.Brief) + kgVars["research_pillars_line"] = libkg.BulletPromptLine( + "內容支柱(延伸知識要往這些方向廣泛展開)", brand.ResearchMap.Pillars) + kgVars["research_questions_line"] = libkg.BulletPromptLine( + "受眾提問方向(可衍生成更多周邊節點)", brand.ResearchMap.Questions) + userPrompt, err = libprompt.KnowledgeGraphLLMUser(kgVars) + if err != nil { + return app.For(code.AI).SysInternal("knowledge graph llm user prompt load failed") + } + default: + updateProgress("蒐集參考資料…", 10) - updateProgress("AI 合成知識圖譜…", 60) + l1Labels := []string{} + if existing != nil { + l1Labels = libkg.L1LabelsFromNodes(existing.Nodes) + } + planIn := kgPlanInput(brand, seed, productBrief, l1Labels, supplemental, expandStrategy) + queries := libkg.PlanQueries(planIn) + if len(prefetchedBrave) > 0 { + queries = libkg.QueriesExcept(queries, prefetchQueries) + } - systemPrompt, err := libprompt.KnowledgeGraphSystem() - if err != nil { - return app.For(code.AI).SysInternal("knowledge graph prompt load failed") + updateProgress(fmt.Sprintf("蒐集參考資料(%d 項查詢)…", len(queries)+len(prefetchQueries)), 25) + + var moreBrave []libkg.BraveSource + if len(queries) > 0 { + moreBrave, err = runBraveKnowledgeExpand(ctx, braveClient, memberCtx, queries, expandStrategy, func(i, total int) { + pct := 25 + ((i + 1) * 30 / max(total, 1)) + updateProgress(fmt.Sprintf("蒐集參考資料 %d/%d…", len(prefetchQueries)+i+1, len(prefetchQueries)+total), pct) + }, func() error { + cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID) + if cancelled { + return errJobCancelled + } + return ctx.Err() + }) + if err != nil { + return err + } + } + braveSources = libkg.MergeBraveSources(prefetchedBrave, moreBrave) + if len(braveSources) == 0 { + return app.For(code.Setting).SvcThirdParty("暫時無法取得參考資料,請稍後重試") + } + + updateProgress("整理延伸知識…", 60) + + systemPrompt, err = libprompt.KnowledgeGraphSystem() + if err != nil { + return app.For(code.AI).SysInternal("knowledge graph prompt load failed") + } + userPrompt, err = libkg.BuildUserPrompt(kgSynthInput(brand, seed, productBrief, braveSources)) + if err != nil { + return app.For(code.AI).SysInternal("knowledge graph user prompt load failed") + } } - userPrompt, err := libkg.BuildUserPrompt(libkg.SynthInput{ - Seed: seed, - ProductBrief: productBrief, - TargetAudience: brand.TargetAudience, - Persona: brand.Brief, - Sources: braveSources, - }) - if err != nil { - return app.For(code.AI).SysInternal("knowledge graph user prompt load failed") - } - result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ + genReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ @@ -178,6 +260,7 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) {Role: "user", Content: userPrompt}, }, }) + result, err := deps.AI.GenerateText(ctx, genReq) if err != nil { return err } @@ -188,47 +271,10 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) TargetAudience: brand.TargetAudience, }, braveSources) if err != nil { - return app.For(code.AI).SvcThirdParty("知識圖譜 LLM 回傳無法解析:" + err.Error()) + return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + err.Error()) } - - if supplemental && existing != nil { - graph = mergeGraphs(existing, graph, braveSources) - } - - needsSupplemental := graph.PainTagCount < libkg.MinPainTagCandidates() && !supplemental - if needsSupplemental { - updateProgress(fmt.Sprintf("痛點 tag 僅 %d,執行補充查詢…", graph.PainTagCount), 75) - suppQueries := libkg.PlanQueries(libkg.PlanInput{ - Seed: seed, - L1Labels: libkg.L1LabelsFromNodes(graph.Nodes), - Supplemental: true, - }) - extraSources, err := runBraveKnowledgeExpand(ctx, braveClient, memberCtx, suppQueries, nil, func() error { - cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID) - if cancelled { - return errJobCancelled - } - return ctx.Err() - }) - if err != nil { - return err - } - braveSources = append(braveSources, extraSources...) - suppInstruction, err := libprompt.KnowledgeGraphSupplemental() - if err != nil { - return app.For(code.AI).SysInternal("knowledge graph supplemental prompt load failed") - } - suppUserPrompt, err := libkg.BuildUserPrompt(libkg.SynthInput{ - Seed: seed, - ProductBrief: productBrief, - TargetAudience: brand.TargetAudience, - Persona: brand.Brief, - Sources: braveSources, - }) - if err != nil { - return err - } - suppResult, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ + if libkg.GraphTooThin(graph) { + retryReq := placement.ResearchGenerateRequest(domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ @@ -236,9 +282,65 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) }, System: systemPrompt, Messages: []domai.Message{ - {Role: "user", Content: suppUserPrompt + "\n\n" + suppInstruction}, + {Role: "user", Content: userPrompt}, + {Role: "assistant", Content: result.Text}, + {Role: "user", Content: libkg.KnowledgeGraphRetryUserPrompt()}, }, }) + if retryResult, retryErr := deps.AI.GenerateText(ctx, retryReq); retryErr == nil { + if retryGraph, parseErr := libkg.ParseSynthOutput(retryResult.Text, libkg.SynthInput{ + Seed: seed, + ProductBrief: productBrief, + TargetAudience: brand.TargetAudience, + }, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) { + graph = retryGraph + } + } + } + + if supplemental && existing != nil { + graph = mergeGraphs(existing, graph, braveSources) + } + + needsBreadthExpand := !supplemental && + (libkg.GraphNeedsBreadth(graph) || graph.PainTagCount < libkg.MinPainTagCandidates()) + if needsBreadthExpand { + updateProgress(fmt.Sprintf("擴充延伸知識廣度(目前 %d 節點)…", len(graph.Nodes)), 75) + planIn := kgPlanInput(brand, seed, productBrief, libkg.L1LabelsFromNodes(graph.Nodes), true, expandStrategy) + if expandStrategy.UsesSupplementalBrave() { + suppQueries := libkg.PlanQueries(planIn) + extraSources, err := runBraveKnowledgeExpand(ctx, braveClient, memberCtx, suppQueries, expandStrategy, nil, func() error { + cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID) + if cancelled { + return errJobCancelled + } + return ctx.Err() + }) + if err != nil { + return err + } + braveSources = append(braveSources, extraSources...) + } + suppInstruction, err := libprompt.KnowledgeGraphSupplemental() + if err != nil { + return app.For(code.AI).SysInternal("knowledge graph supplemental prompt load failed") + } + suppUserPrompt, err := libkg.BuildUserPrompt(kgSynthInput(brand, seed, productBrief, braveSources)) + if err != nil { + return err + } + breadthPrompt := libkg.KnowledgeGraphBreadthUserPrompt(len(graph.Nodes)) + suppResult, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{ + APIKey: credential.APIKey, + }, + System: systemPrompt, + Messages: []domai.Message{ + {Role: "user", Content: suppUserPrompt + "\n\n" + suppInstruction + "\n\n" + breadthPrompt}, + }, + })) if err == nil { if patched, parseErr := libkg.ParseSynthOutput(suppResult.Text, libkg.SynthInput{Seed: seed}, braveSources); parseErr == nil { graph = mergeGraphs(&kgusecase.GraphSummary{ @@ -248,35 +350,68 @@ func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) }, patched, braveSources) } } - needsSupplemental = graph.PainTagCount < libkg.MinPainTagCandidates() } - updateProgress("寫入知識圖譜…", 90) + if libkg.GraphNeedsBootstrap(graph) { + updateProgress(fmt.Sprintf("從研究地圖補齊延伸知識(目前 %d 節點)…", len(graph.Nodes)), 85) + libkg.SupplementGraphFromResearchMap(&graph, seed, brand.ResearchMap.Pillars, brand.ResearchMap.Questions) + } + patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief) + libkg.DeriveSearchTagsFromGraph(&graph, patrolInput) + + updateProgress("整理海巡關鍵字…", 88) + if err := syncAutoPatrolKeywords(ctx, deps, tenantID, ownerUID, scope, graph.Nodes, productBrief); err != nil { + return err + } + if scope.TopicID != "" { + topic, topicErr := deps.PlacementTopic.Get(ctx, tenantID, ownerUID, scope.TopicID) + if topicErr == nil && topic != nil { + brand = placementTopicAsBrand(scope, topic) + } + } else { + brand, err = deps.Brand.Get(ctx, tenantID, ownerUID, brandID) + if err != nil { + return err + } + } + + updateProgress("儲存研究地圖…", 90) graph.BraveSources = braveSources now := clock.NowUnixNano() saved, err := deps.KnowledgeGraph.Upsert(ctx, kgusecase.UpsertRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - BrandID: brandID, - Seed: graph.Seed, - Nodes: graph.Nodes, - Edges: graph.Edges, - BraveSources: graph.BraveSources, - PainTagCount: graph.PainTagCount, - GeneratedAt: now, + TenantID: tenantID, + OwnerUID: ownerUID, + BrandID: brandID, + TopicID: scope.TopicID, + Seed: graph.Seed, + Nodes: graph.Nodes, + Edges: graph.Edges, + BraveSources: graph.BraveSources, + ExpandStrategy: expandStrategy.String(), + PainTagCount: graph.PainTagCount, + GeneratedAt: now, }) if err != nil { return err } + if err := syncResearchMapSources(ctx, deps, tenantID, ownerUID, scope, expandStrategy.String(), braveSources); err != nil { + return err + } + + nextRoute := "/research?brand=" + brandID + if scope.TopicID != "" { + nextRoute = "/placement/topics/" + scope.TopicID + "/research-map" + } handoff := map[string]any{ "flow": "placement", "brand_id": brandID, + "topic_id": scope.TopicID, "pain_tag_count": saved.PainTagCount, "summary": fmt.Sprintf("圖譜 %d 節點,痛點候選 %d", len(saved.Nodes), saved.PainTagCount), - "next_route": "/research?brand=" + brandID, - "needs_supplemental_expand": needsSupplemental, + "next_route": nextRoute, + "needs_supplemental_expand": saved.PainTagCount < libkg.MinPainTagCandidates() || len(saved.Nodes) < libkg.MinBreadthGraphNodes(), "search_source_mode": string(memberCtx.SearchSourceMode), "dev_mode": memberCtx.DevMode, } @@ -300,44 +435,71 @@ func runBraveKnowledgeExpand( client *libbrave.Client, member placement.MemberContext, queries []string, + strategy libkg.ExpandStrategy, onProgress func(i, total int), heartbeat func() error, ) ([]libkg.BraveSource, error) { if client == nil || !client.Enabled() { - return nil, app.For(code.Setting).InputMissingRequired("請在設定頁設定 Brave Search API key(跟隨此登入帳號)") + return nil, app.For(code.Setting).InputMissingRequired("請在設定頁完成研究資料連線") } - out := make([]libkg.BraveSource, 0, len(queries)*2) - for i, query := range queries { - if heartbeat != nil { - if err := heartbeat(); err != nil { - return nil, err - } - } - res, _ := client.Search(ctx, libbrave.SearchOptions{ - Query: query, - Limit: 3, - Mode: libbrave.ModeKnowledgeExpand, - Country: member.BraveCountry, - SearchLang: member.BraveSearchLang, - }) - for _, item := range res.Results { - out = append(out, libkg.BraveSource{ - Query: query, - Snippet: item.Snippet, - URL: item.URL, - Title: item.Title, - }) - } - if onProgress != nil { - onProgress(i, len(queries)) + if len(queries) == 0 { + if strategy == libkg.ExpandStrategyHybrid { + return nil, nil } + return nil, app.For(code.Setting).InputMissingRequired("沒有可執行的 Brave 查詢") } + cfg := libkg.DefaultBraveCollectConfig() + out := libkg.CollectBraveSources(ctx, client, libkg.BraveSearchLocale{ + Country: member.BraveCountry, + SearchLang: member.BraveSearchLang, + }, queries, cfg, onProgress, heartbeat) if len(out) == 0 { - return nil, app.For(code.Setting).SvcThirdParty("Brave 查詢無結果,請確認 API key 或稍後重試") + return nil, app.For(code.Setting).SvcThirdParty("暫時無法取得參考資料,請稍後重試") } return out, nil } +func kgPlanInput( + brand *branddomain.BrandSummary, + seed, productBrief string, + l1Labels []string, + supplemental bool, + strategy libkg.ExpandStrategy, +) libkg.PlanInput { + return libkg.PlanInput{ + Seed: seed, + TargetAudience: brand.TargetAudience, + ProductBrief: productBrief, + Pillars: brand.ResearchMap.Pillars, + Questions: brand.ResearchMap.Questions, + PatrolKeywords: brand.ResearchMap.PatrolKeywords, + L1Labels: l1Labels, + Supplemental: supplemental, + Strategy: strategy, + } +} + +func kgSynthInput( + brand *branddomain.BrandSummary, + seed, productBrief string, + sources []libkg.BraveSource, +) libkg.SynthInput { + topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief) + return libkg.SynthInput{ + BrandDisplayName: topicCtx.BrandDisplayName, + TopicName: topicCtx.TopicName, + ProductLabel: topicCtx.ProductDisplayName(), + Goals: topicCtx.Goals, + Seed: seed, + ProductBrief: productBrief, + TargetAudience: brand.TargetAudience, + Persona: brand.Brief, + ResearchPillars: brand.ResearchMap.Pillars, + ResearchQuestions: brand.ResearchMap.Questions, + Sources: sources, + } +} + func mergeGraphs(existing *kgusecase.GraphSummary, incoming libkg.Graph, extraSources []libkg.BraveSource) libkg.Graph { if existing == nil { return incoming @@ -373,7 +535,6 @@ func mergeGraphs(existing *kgusecase.GraphSummary, incoming libkg.Graph, extraSo merged.Edges = append(merged.Edges, edge) } merged.BraveSources = append(merged.BraveSources, extraSources...) - libkg.DeriveSearchTagsFromGraph(&merged) return merged } @@ -418,6 +579,86 @@ func max(a, b int) int { return b } +func syncAutoPatrolKeywords( + ctx context.Context, + deps ExpandGraphDeps, + tenantID, ownerUID string, + scope *placementScope, + nodes []libkg.Node, + productBrief string, +) error { + if scope == nil || scope.Brand == nil { + return nil + } + fresh := scope.Brand + if scope.TopicID != "" { + topic, err := deps.PlacementTopic.Get(ctx, tenantID, ownerUID, scope.TopicID) + if err != nil { + return err + } + fresh = placementTopicAsBrand(scope, topic) + } else { + loaded, err := deps.Brand.Get(ctx, tenantID, ownerUID, scope.CatalogBrand) + if err != nil { + return err + } + fresh = loaded + } + patrolInput := libkg.PatrolTagInputFromBrand(fresh, productBrief) + tags := libkg.CollectPatrolTagsFromGraph(patrolInput, nodes) + if len(tags) == 0 { + return nil + } + entityMap := brandentity.ResearchMap{ + AudienceSummary: fresh.ResearchMap.AudienceSummary, + ContentGoal: fresh.ResearchMap.ContentGoal, + Questions: fresh.ResearchMap.Questions, + Pillars: fresh.ResearchMap.Pillars, + Exclusions: fresh.ResearchMap.Exclusions, + ResearchItems: fresh.ResearchMap.ResearchItems, + ExpandStrategy: fresh.ResearchMap.ExpandStrategy, + PatrolKeywords: tags, + } + return updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, nil) +} + +func syncResearchMapSources( + ctx context.Context, + deps ExpandGraphDeps, + tenantID, ownerUID string, + scope *placementScope, + expandStrategy string, + sources []libkg.BraveSource, +) error { + if expandStrategy == "" || scope == nil || scope.Brand == nil { + return nil + } + items := make([]brandentity.ResearchItem, 0, len(sources)) + for _, src := range sources { + if strings.TrimSpace(src.URL) == "" && strings.TrimSpace(src.Snippet) == "" { + continue + } + items = append(items, brandentity.ResearchItem{ + Title: src.Title, + URL: src.URL, + Snippet: src.Snippet, + Query: src.Query, + }) + } + fresh := scope.Brand + entityMap := brandentity.ResearchMap{ + AudienceSummary: fresh.ResearchMap.AudienceSummary, + ContentGoal: fresh.ResearchMap.ContentGoal, + Questions: fresh.ResearchMap.Questions, + Pillars: fresh.ResearchMap.Pillars, + Exclusions: fresh.ResearchMap.Exclusions, + ResearchItems: items, + ExpandStrategy: expandStrategy, + PatrolKeywords: fresh.ResearchMap.PatrolKeywords, + } + return updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, nil) +} + func ensureResearchMap( ctx context.Context, step StepContext, @@ -434,7 +675,28 @@ func ensureResearchMap( return fmt.Errorf("research map: missing actor or brand") } - result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{ + topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief) + mapInput := topicCtx.ToResearchMapInput() + + updateProgress("分析主題脈絡…", 6) + analysisResult, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{ + APIKey: credential.APIKey, + }, + System: placement.BuildResearchMapAnalysisSystemPrompt(), + Messages: []domai.Message{ + {Role: "user", Content: placement.BuildResearchMapAnalysisUserPrompt(mapInput)}, + }, + })) + if err != nil { + return err + } + + finalPrompt := placement.BuildResearchMapFinalizeUserPrompt(mapInput, analysisResult.Text) + updateProgress("產出研究地圖…", 7) + result, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ Provider: providerID, Model: credential.Model, Credential: domai.Credential{ @@ -442,24 +704,35 @@ func ensureResearchMap( }, System: placement.BuildResearchMapSystemPrompt(), Messages: []domai.Message{ - { - Role: "user", - Content: placement.BuildResearchMapUserPrompt(placement.ResearchMapInput{ - Label: brand.DisplayName, - SeedQuery: brand.SeedQuery, - Brief: brand.Brief, - ProductContext: brand.ProductContext, - }), - }, + {Role: "user", Content: finalPrompt}, }, - }) + })) if err != nil { return err } parsed, err := placement.ParseResearchMapOutput(result.Text) if err != nil { - return app.For(code.AI).SvcThirdParty("研究地圖 LLM 回傳無法解析:" + err.Error()) + return app.For(code.AI).SvcThirdParty("研究地圖產生失敗,請重試:" + err.Error()) + } + if placement.ResearchMapTooThin(parsed) { + if retryResult, retryErr := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{ + Provider: providerID, + Model: credential.Model, + Credential: domai.Credential{ + APIKey: credential.APIKey, + }, + System: placement.BuildResearchMapSystemPrompt(), + Messages: []domai.Message{ + {Role: "user", Content: finalPrompt}, + {Role: "assistant", Content: result.Text}, + {Role: "user", Content: placement.ResearchMapRetryUserPrompt()}, + }, + })); retryErr == nil { + if retryParsed, parseErr := placement.ParseResearchMapOutput(retryResult.Text); parseErr == nil && !placement.ResearchMapTooThin(retryParsed) { + parsed = retryParsed + } + } } entityMap := brandentity.ResearchMap{ @@ -468,30 +741,55 @@ func ensureResearchMap( Questions: parsed.Questions, Pillars: parsed.Pillars, Exclusions: parsed.Exclusions, + PatrolKeywords: libkg.SanitizePatrolKeywordList(parsed.PatrolKeywords), } targetAudience := strings.TrimSpace(brand.TargetAudience) if targetAudience == "" { targetAudience = parsed.AudienceSummary } - patch := branddomain.BrandPatch{ - ResearchMap: &entityMap, + topicID := topicIDFromPayload(step.Run.Payload) + scope := &placementScope{ + TopicID: topicID, + CatalogBrand: brand.ID, + Brand: brand, } - if targetAudience != "" { - patch.TargetAudience = &targetAudience - } - if strings.TrimSpace(brand.ProductBrief) == "" && productBrief != "" { - patch.ProductBrief = &productBrief - } - - _, err = deps.Brand.Update(ctx, branddomain.UpdateRequest{ - TenantID: tenantID, - OwnerUID: ownerUID, - BrandID: brand.ID, - Patch: patch, - }) - if err != nil { + if err := updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, &targetAudience); err != nil { return err } updateProgress("研究地圖已就緒", 8) return nil } + +func updatePlacementResearchMap( + ctx context.Context, + deps ExpandGraphDeps, + tenantID, ownerUID string, + scope *placementScope, + entityMap brandentity.ResearchMap, + targetAudience *string, +) error { + if scope == nil { + return nil + } + if scope.TopicID != "" { + patch := topicdomain.TopicPatch{ResearchMap: &entityMap} + _, err := deps.PlacementTopic.Update(ctx, topicdomain.UpdateRequest{ + TenantID: tenantID, + OwnerUID: ownerUID, + TopicID: scope.TopicID, + Patch: patch, + }) + return err + } + patch := branddomain.BrandPatch{ResearchMap: &entityMap} + if targetAudience != nil && strings.TrimSpace(*targetAudience) != "" { + patch.TargetAudience = targetAudience + } + _, err := deps.Brand.Update(ctx, branddomain.UpdateRequest{ + TenantID: tenantID, + OwnerUID: ownerUID, + BrandID: scope.CatalogBrand, + Patch: patch, + }) + return err +} diff --git a/haixun-backend/internal/worker/job/placement_scope.go b/haixun-backend/internal/worker/job/placement_scope.go new file mode 100644 index 0000000..860c676 --- /dev/null +++ b/haixun-backend/internal/worker/job/placement_scope.go @@ -0,0 +1,78 @@ +package job + +import ( + "context" + "strings" + + branddomain "haixun-backend/internal/model/brand/domain/usecase" + topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase" +) + +type placementScope struct { + TopicID string + CatalogBrand string + Topic *topicdomain.TopicSummary + Brand *branddomain.BrandSummary +} + +func topicIDFromPayload(payload map[string]any) string { + return strings.TrimSpace(stringField(payload, "topic_id")) +} + +func resolvePlacementScope( + ctx context.Context, + brandUC branddomain.UseCase, + topicUC topicdomain.UseCase, + tenantID, ownerUID string, + payload map[string]any, +) (*placementScope, error) { + topicID := topicIDFromPayload(payload) + catalogBrandID := strings.TrimSpace(stringField(payload, "brand_id")) + if catalogBrandID == "" { + catalogBrandID = brandIDFromPayload(payload) + } + if topicID != "" { + topic, err := topicUC.Get(ctx, tenantID, ownerUID, topicID) + if err != nil { + return nil, err + } + catalogBrandID = topic.BrandID + catalog, err := brandUC.Get(ctx, tenantID, ownerUID, catalogBrandID) + if err != nil { + return nil, err + } + merged := *catalog + merged.TopicName = topic.TopicName + merged.SeedQuery = topic.SeedQuery + merged.Brief = topic.Brief + merged.ProductID = topic.ProductID + merged.ResearchMap = topic.ResearchMap + return &placementScope{ + TopicID: topicID, + CatalogBrand: catalogBrandID, + Topic: topic, + Brand: &merged, + }, nil + } + brand, err := brandUC.Get(ctx, tenantID, ownerUID, catalogBrandID) + if err != nil { + return nil, err + } + return &placementScope{ + CatalogBrand: catalogBrandID, + Brand: brand, + }, nil +} + +func placementTopicAsBrand(scope *placementScope, topic *topicdomain.TopicSummary) *branddomain.BrandSummary { + if scope == nil || scope.Brand == nil || topic == nil { + return scope.Brand + } + out := *scope.Brand + out.TopicName = topic.TopicName + out.SeedQuery = topic.SeedQuery + out.Brief = topic.Brief + out.ProductID = topic.ProductID + out.ResearchMap = topic.ResearchMap + return &out +} diff --git a/haixun-backend/internal/worker/job/runner.go b/haixun-backend/internal/worker/job/runner.go index 76a08ce..b9e7c6f 100644 --- a/haixun-backend/internal/worker/job/runner.go +++ b/haixun-backend/internal/worker/job/runner.go @@ -191,7 +191,7 @@ func (r *Runner) runStep(ctx context.Context, run *entity.Run, template *entity. Template: template, Step: step, Heartbeat: func(ctx context.Context) error { - return r.jobs.RefreshRunLock(ctx, jobID, r.workerID, 300) + return r.jobs.RefreshRunLock(ctx, jobID, r.workerID, 600) }, }) } diff --git a/haixun-backend/internal/worker/job/scan_placement.go b/haixun-backend/internal/worker/job/scan_placement.go index 388478a..4850a9c 100644 --- a/haixun-backend/internal/worker/job/scan_placement.go +++ b/haixun-backend/internal/worker/job/scan_placement.go @@ -6,6 +6,8 @@ import ( "strings" libbrave "haixun-backend/internal/library/brave" + app "haixun-backend/internal/library/errors" + "haixun-backend/internal/library/errors/code" libkg "haixun-backend/internal/library/knowledge" "haixun-backend/internal/library/placement" branddomain "haixun-backend/internal/model/brand/domain/usecase" @@ -45,22 +47,57 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD return fmt.Errorf("placement-scan payload missing tenant_id, owner_uid, or brand_id") } - graph, err := deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID) - if err != nil { - return err + patrolMode := boolField(payload, "patrol_mode") + + brand, brandErr := deps.Brand.Get(ctx, tenantID, ownerUID, brandID) + if brandErr != nil { + return brandErr } - if graphID == "" { + if brand == nil { + return fmt.Errorf("brand not found") + } + + graph, graphErr := deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID) + if graphErr != nil { + if patrolMode && isKnowledgeGraphNotFound(graphErr) { + graph = nil + if graphID == "" { + graphID = brandID + } + } else { + return graphErr + } + } else if graphID == "" { graphID = graph.ID } - nodes := graph.Nodes - if ids := stringSliceField(payload, "node_ids"); len(ids) > 0 { - nodes = filterNodesByIDs(graph.Nodes, ids) - } else { - nodes = selectedNodes(graph.Nodes) + patrolKeywords := []string{} + if patrolMode { + patrolKeywords = stringSliceField(payload, "patrol_keywords") + if len(patrolKeywords) == 0 { + patrolKeywords = libkg.NormalizePatrolKeywordList(brand.ResearchMap.PatrolKeywords) + } + if len(patrolKeywords) == 0 { + return fmt.Errorf("請先在研究地圖填寫要回覆的海巡關鍵字") + } } - if len(nodes) == 0 { - return fmt.Errorf("請先在研究頁勾選至少一個節點") + + nodes := []libkg.Node{} + if graph != nil { + nodes = graph.Nodes + } + if !patrolMode { + if graph == nil { + return fmt.Errorf("請先產生延伸知識圖譜,或改用手動海巡關鍵字") + } + if ids := stringSliceField(payload, "node_ids"); len(ids) > 0 { + nodes = filterNodesByIDs(graph.Nodes, ids) + } else { + nodes = selectedNodes(graph.Nodes) + } + if len(nodes) == 0 { + return fmt.Errorf("請先在研究地圖填寫要回覆的海巡關鍵字") + } } research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID) @@ -74,12 +111,15 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler { return fmt.Errorf("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session 設定") } - if memberCtx.AllowsBrave && strings.TrimSpace(memberCtx.BraveAPIKey) == "" { + if placement.MemberNeedsBraveKey(memberCtx) && strings.TrimSpace(memberCtx.BraveAPIKey) == "" { return fmt.Errorf("請在設定頁設定 Brave Search API key(跟隨此登入帳號)") } if memberCtx.DevMode && !memberCtx.BrowserConnected { return fmt.Errorf("開發模式需先同步 Chrome Session") } + if !memberCtx.DevMode && memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected { + return fmt.Errorf("正式模式需先完成 Threads API 連線") + } updateProgress := func(summary string, percentage int) { _ = step.Heartbeat(ctx) @@ -92,21 +132,50 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD }) } - exclusions := []string{} - if brand, brandErr := deps.Brand.Get(ctx, tenantID, ownerUID, brandID); brandErr == nil && brand != nil { - exclusions = append(exclusions, brand.ResearchMap.Exclusions...) + exclusions := append([]string{}, brand.ResearchMap.Exclusions...) + + if len(patrolKeywords) > 0 { + updateProgress(fmt.Sprintf("依 %d 組海巡關鍵字準備雙軌搜尋…", len(patrolKeywords)), 5) + } else { + updateProgress("準備置入海巡…", 5) } - updateProgress("準備置入海巡…", 5) + if err := deps.ScanPost.ClearBrandScan(ctx, tenantID, ownerUID, brandID); err != nil { + return err + } braveClient := libbrave.NewClient(memberCtx.BraveAPIKey) - crawlerFn := makeCrawlerSearchFn(deps, tenantID, ownerUID) + crawlerFn := placement.WrapPoliteCrawler(makeCrawlerSearchFn(deps, tenantID, ownerUID)) + graphNodes := []libkg.Node{} + if graph != nil { + graphNodes = graph.Nodes + } + checkpointReq := scanpostusecase.CheckpointRequest{ + TenantID: tenantID, + OwnerUID: ownerUID, + BrandID: brandID, + GraphID: graphID, + ScanJobID: step.JobID, + } candidates, err := placement.RunDualTrackDiscover(ctx, placement.DualTrackInput{ - Nodes: nodes, - Exclusions: exclusions, - Member: memberCtx, - Client: braveClient, - Crawler: crawlerFn, + Nodes: graphNodes, + PatrolKeywords: patrolKeywords, + Exclusions: exclusions, + Member: memberCtx, + Client: braveClient, + Crawler: crawlerFn, + OnCheckpoint: func(batch []placement.ScanCandidate) error { + if len(batch) == 0 { + return nil + } + checkpointReq.Posts = batch + saved, err := deps.ScanPost.UpsertScanCheckpoint(ctx, checkpointReq) + if err != nil { + return err + } + updateProgress(fmt.Sprintf("已儲存 %d 篇候選貼文(邊爬邊存)", saved), -1) + return nil + }, }, updateProgress) if err != nil { return err @@ -128,8 +197,8 @@ func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementD }) } - updateProgress(fmt.Sprintf("寫入 %d 篇海巡結果…", len(candidates)), 92) - count, err := deps.ScanPost.ReplaceFromScan(ctx, scanpostusecase.ReplaceRequest{ + updateProgress(fmt.Sprintf("整理 %d 篇海巡結果…", len(candidates)), 92) + count, err := deps.ScanPost.FinalizeScan(ctx, scanpostusecase.ReplaceRequest{ TenantID: tenantID, OwnerUID: ownerUID, BrandID: brandID, @@ -221,6 +290,16 @@ func filterNodesByIDs(nodes []libkg.Node, ids []string) []libkg.Node { return out } +func isKnowledgeGraphNotFound(err error) bool { + if err == nil { + return false + } + if e := app.FromError(err); e != nil && e.Category() == code.ResNotFound { + return true + } + return strings.Contains(strings.ToLower(err.Error()), "knowledge graph not found") +} + func stringSliceField(payload map[string]any, key string) []string { if payload == nil { return nil diff --git a/haixun-backend/web/public/downloads/haixun-threads-sync.zip b/haixun-backend/web/public/downloads/haixun-threads-sync.zip index d8a2a7d..9c155da 100644 Binary files a/haixun-backend/web/public/downloads/haixun-threads-sync.zip and b/haixun-backend/web/public/downloads/haixun-threads-sync.zip differ diff --git a/haixun-backend/web/src/App.tsx b/haixun-backend/web/src/App.tsx index e157d24..7fa9fab 100644 --- a/haixun-backend/web/src/App.tsx +++ b/haixun-backend/web/src/App.tsx @@ -13,11 +13,13 @@ import { LoginPage } from './pages/LoginPage' import { PermissionsPage } from './pages/PermissionsPage' import { LegacyBrandRouteRedirect } from './components/LegacyBrandRouteRedirect' import { BrandDetailPage } from './pages/BrandDetailPage' +import { BrandProductEditPage } from './pages/BrandProductEditPage' import { BrandsPage } from './pages/BrandsPage' import { PersonaDetailPage } from './pages/PersonaDetailPage' -import { PersonaMatrixPage } from './pages/PersonaMatrixPage' import { MatrixEntryRoute } from './components/MatrixEntryRoute' import { OutreachEntryRoute } from './components/OutreachEntryRoute' +import { PlacementTopicResearchMapPage } from './pages/PlacementTopicResearchMapPage' +import { PlacementTopicSettingsPage } from './pages/PlacementTopicSettingsPage' import { PlacementTopicsPage } from './pages/PlacementTopicsPage' import { PersonaResearchPage } from './pages/PersonaResearchPage' import { PersonasPage } from './pages/PersonasPage' @@ -40,16 +42,19 @@ export default function App() { }> } /> } /> + } /> } /> + } /> + } /> } /> } /> } /> } /> - } /> + } /> } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -72,4 +77,4 @@ export default function App() { ) -} \ No newline at end of file +} diff --git a/haixun-backend/web/src/api/client.ts b/haixun-backend/web/src/api/client.ts index 4e0b238..eddbf35 100644 --- a/haixun-backend/web/src/api/client.ts +++ b/haixun-backend/web/src/api/client.ts @@ -47,9 +47,11 @@ async function parseEnvelope(res: Response): Promise> { if (!text.trim()) { if (!res.ok) { const hint = - res.status === 405 - ? '後端尚未支援此操作,請重啟 API 服務(make run 或 scripts/restart-all.sh)' - : res.status >= 500 + res.status === 404 + ? '後端找不到此 API,請確認已執行 make gen-api 並重啟 API(make restart-all)' + : res.status === 405 + ? '後端尚未支援此操作,請重啟 API 服務(make run 或 scripts/restart-all.sh)' + : res.status >= 500 ? '後端 API 無法連線,請確認服務已啟動(scripts/start-all.sh 或 make -C haixun-backend start-all)' : `HTTP ${res.status}` throw new ApiError(res.status, hint) diff --git a/haixun-backend/web/src/components/AccountConnectionMode.tsx b/haixun-backend/web/src/components/AccountConnectionMode.tsx index 6833237..4841af2 100644 --- a/haixun-backend/web/src/components/AccountConnectionMode.tsx +++ b/haixun-backend/web/src/components/AccountConnectionMode.tsx @@ -5,6 +5,12 @@ import { connectionModeLabel, connectionReadyLabel, } from '../lib/connectionMode' +import { + normalizeSearchSourceMode, + SEARCH_SOURCE_OPTIONS, + searchSourceModeToApi, + type SearchSourceMode, +} from '../lib/searchSourceMode' import type { ThreadsAccountConnectionData } from '../types/api' import { useOnboarding } from '../onboarding/OnboardingContext' import { AcLink, Badge, ChoiceCard, ErrorText, SectionTitle, SuccessText } from './ui' @@ -44,6 +50,27 @@ export function AccountConnectionMode({ accountId, connectionsPath }: AccountCon load().catch(() => undefined) }, [accountId]) + const saveSearchSourceMode = async (mode: SearchSourceMode) => { + if (!accountId) return + setBusy(true) + setError('') + setMessage('') + try { + const data = await api.patch( + `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`, + { search_source_mode: searchSourceModeToApi(mode) }, + { auth: true }, + ) + setConnection(data) + setMessage('已更新海巡搜尋來源') + await refreshOnboarding() + } catch (e) { + setError(e instanceof ApiError ? e.message : '儲存失敗') + } finally { + setBusy(false) + } + } + const saveDevMode = async (devMode: boolean) => { if (!accountId) return setBusy(true) @@ -67,6 +94,7 @@ export function AccountConnectionMode({ accountId, connectionsPath }: AccountCon const prefs = connection?.prefs const devMode = !!prefs?.dev_mode + const searchSourceMode = normalizeSearchSourceMode(prefs?.search_source_mode) return (
@@ -119,6 +147,30 @@ export function AccountConnectionMode({ accountId, connectionsPath }: AccountCon onClick={() => saveDevMode(true)} />
+ {!devMode ? ( +
+ 海巡搜尋來源 +

+ 正式模式下的貼文搜尋管道。開發模式一律走本機爬蟲(已內建禮貌間隔,降低被封風險)。 +

+
+ {SEARCH_SOURCE_OPTIONS.map((option) => ( + saveSearchSourceMode(option.id)} + /> + ))} +
+
+ ) : ( +

+ 開發模式使用 Playwright 爬蟲,每次查詢間隔約 8~12 秒,避免 Threads 限流。 +

+ )} ) : (

無法載入連線設定。

diff --git a/haixun-backend/web/src/components/AppSidebar.tsx b/haixun-backend/web/src/components/AppSidebar.tsx index c980267..e2bed2f 100644 --- a/haixun-backend/web/src/components/AppSidebar.tsx +++ b/haixun-backend/web/src/components/AppSidebar.tsx @@ -2,11 +2,11 @@ import { useMemo } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { navGroupsForOnboarding, onboardingGlowClass, shouldGlowNav } from '../lib/onboarding' import { coreWorkflowNavGroup, type AcAppKey } from '../lib/acAssets' -import { getActiveBrandId } from '../lib/brandContext' +import { getActiveTopicId } from '../lib/brandContext' import { - brandIdFromSearch, isPlacementFlowPath, placementFlowPath, + topicIdFromSearch, } from '../lib/placementFlow' import { useOnboarding } from '../onboarding/OnboardingContext' import { AcIcon } from './AcIcon' @@ -27,16 +27,16 @@ function SidebarNavItem({ guideGlow?: boolean }) { const { pathname, search } = useLocation() - const activeBrandId = brandIdFromSearch(search) || getActiveBrandId() - const linkTo = isPlacementFlowPath(to) ? placementFlowPath(to, activeBrandId) : to - const placementFlowActive = to === '/outreach' && isPlacementFlowPath(pathname) + const activeTopicId = topicIdFromSearch(search) || getActiveTopicId() + const linkTo = isPlacementFlowPath(to) ? placementFlowPath(to, activeTopicId) : to + const findTAActive = to === '/placement/topics' && isPlacementFlowPath(pathname) return ( { const prefixActive = matchPrefix ? pathname.startsWith(matchPrefix) : false - const active = isActive || prefixActive || placementFlowActive + const active = isActive || prefixActive || findTAActive return `ac-sidebar-nav-item relative ${active ? 'ac-sidebar-nav-item--active' : ''} ${onboardingGlowClass(!!guideGlow)}` }} > diff --git a/haixun-backend/web/src/components/BrandProductPicker.tsx b/haixun-backend/web/src/components/BrandProductPicker.tsx new file mode 100644 index 0000000..2e61aa6 --- /dev/null +++ b/haixun-backend/web/src/components/BrandProductPicker.tsx @@ -0,0 +1,151 @@ +import { useMemo } from 'react' +import { Link } from 'react-router-dom' +import type { BrandData } from '../types/brand' +import { + formatCatalogProductSummary, + summarizeCatalogProduct, + toCatalogBrand, +} from '../lib/productCatalog' +import { Field, Select } from './ui' + +const AUTO_VALUE = '__auto__' +const NONE_VALUE = '__none__' + +type BrandProductPickerProps = { + brands: BrandData[] + brandId: string + productId: string | null + onBrandChange: (brandId: string) => void + onProductChange: (productId: string | null) => void + label?: string + /** 主題已綁定品牌時,只選產品、不換品牌 */ + brandLocked?: boolean +} + +export function BrandProductPicker({ + brands, + brandId, + productId, + onBrandChange, + onProductChange, + label = '置入品牌與產品', + brandLocked = false, +}: BrandProductPickerProps) { + const catalogBrands = useMemo(() => brands.map(toCatalogBrand), [brands]) + const selectedBrand = useMemo( + () => catalogBrands.find((item) => item.id === brandId) ?? null, + [catalogBrands, brandId], + ) + const selectedProduct = useMemo(() => { + if (!selectedBrand || !productId) return null + return selectedBrand.products.find((item) => item.id === productId) ?? null + }, [selectedBrand, productId]) + + const preview = useMemo(() => { + if (!selectedBrand) return null + if (selectedProduct) { + return formatCatalogProductSummary( + selectedBrand.name, + selectedProduct.context, + selectedProduct.label, + ) + } + if (selectedBrand.products.length > 0) { + return `${selectedBrand.name} · ${selectedBrand.products.length} 個產品(海巡時依標籤自動推薦)` + } + const legacy = brands.find((item) => item.id === brandId) + if (legacy?.product_context) { + return summarizeCatalogProduct( + { + id: 'legacy', + label: legacy.display_name || '產品', + product_context: legacy.product_context, + create_at: 0, + update_at: 0, + }, + selectedBrand.name, + ) + } + return null + }, [selectedBrand, selectedProduct, brands, brandId]) + + return ( +
+
+

{label}

+ + 管理品牌與產品 + +
+ + {brandLocked && selectedBrand ? ( + +

+ {selectedBrand.name} +

+
+ ) : ( + + + + )} + + {selectedBrand && selectedBrand.products.length > 0 ? ( + + + + ) : null} + + {selectedBrand && selectedBrand.products.length === 0 ? ( +

+ 此品牌尚無產品。 + + 到品牌庫新增 + +

+ ) : null} + + {preview ? ( +
+

產品定位預覽

+

{preview}

+
+ ) : null} +
+ ) +} \ No newline at end of file diff --git a/haixun-backend/web/src/components/BrandProductsPanel.tsx b/haixun-backend/web/src/components/BrandProductsPanel.tsx new file mode 100644 index 0000000..8ad7de8 --- /dev/null +++ b/haixun-backend/web/src/components/BrandProductsPanel.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { api, ApiError } from '../api/client' +import { summarizeProductContext } from '../lib/productContext' +import type { BrandData, BrandProductData } from '../types/brand' +import { Badge, Button, Card, ErrorText, SectionTitle, TableAction } from './ui' + +type Props = { + brand: BrandData + /** 路由上的品牌 id;避免 draft 尚未載入時 brand.id 為空 */ + brandId?: string + onProductsChange: (products: BrandProductData[]) => void +} + +function productSummary(item: BrandProductData): string { + return summarizeProductContext(item.product_context) || item.label +} + +export function BrandProductsPanel({ brand, brandId: brandIdProp, onProductsChange }: Props) { + const navigate = useNavigate() + const [products, setProducts] = useState(brand.products ?? []) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [deletingId, setDeletingId] = useState(null) + + const brandId = (brandIdProp ?? brand.id).trim() + const newProductPath = `/brands/${encodeURIComponent(brandId)}/products/new` + const editProductPath = (productId: string) => + `/brands/${encodeURIComponent(brandId)}/products/${encodeURIComponent(productId)}` + + const syncProducts = (list: BrandProductData[]) => { + setProducts(list) + onProductsChange(list) + } + + const load = useCallback(async () => { + if (!brandId) return + setLoading(true) + setError('') + try { + const data = await api.get<{ list: BrandProductData[] }>( + `/api/v1/brands/${encodeURIComponent(brandId)}/products`, + { auth: true }, + ) + syncProducts(data.list ?? []) + } catch (e) { + setError(e instanceof ApiError ? e.message : '載入產品失敗') + } finally { + setLoading(false) + } + }, [brandId]) + + useEffect(() => { + void load() + }, [load]) + + const remove = async (item: BrandProductData) => { + if (!window.confirm(`確定刪除產品「${item.label}」?`)) return + setDeletingId(item.id) + setError('') + try { + await api.delete( + `/api/v1/brands/${encodeURIComponent(brandId)}/products/${encodeURIComponent(item.id)}`, + { auth: true }, + ) + await load() + } catch (e) { + setError(e instanceof ApiError ? e.message : '刪除失敗') + } finally { + setDeletingId(null) + } + } + + return ( + +
+
+ 產品目錄 + {products.length ? ( + {products.length} 個產品 + ) : ( + 一個品牌可對應多個產品線 + )} +
+ +
+ + {loading ? ( +

載入中…

+ ) : products.length ? ( +
+ {products.map((item) => ( +
+
+ +

{item.label}

+

+ {productSummary(item)} +

+ {item.match_tags?.length ? ( +
+ {item.match_tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null} + +
+ navigate(editProductPath(item.id))}>編輯 + void remove(item)} + > + {deletingId === item.id ? '刪除中…' : '刪除'} + +
+
+
+ ))} +
+ ) : ( +
+

尚無產品。

+ +
+ )} + + +
+ ) +} \ No newline at end of file diff --git a/haixun-backend/web/src/components/CreatePlacementTopicDialog.tsx b/haixun-backend/web/src/components/CreatePlacementTopicDialog.tsx index 1879da1..d01e11a 100644 --- a/haixun-backend/web/src/components/CreatePlacementTopicDialog.tsx +++ b/haixun-backend/web/src/components/CreatePlacementTopicDialog.tsx @@ -1,58 +1,120 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' import { api, ApiError } from '../api/client' -import { hasProductContext } from '../lib/productContext' -import type { CreatePlacementTopicData } from '../types/brand' -import { ProductContextForm } from './ProductContextForm' +import { getActiveBrandId } from '../lib/brandContext' +import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph' +import { brandHasCatalogProducts } from '../lib/productCatalog' +import { isCatalogBrand } from '../lib/productCatalog' +import type { BrandData } from '../types/brand' +import type { CreatePlacementTopicData, PlacementTopicData } from '../types/placementTopic' +import { BrandProductPicker } from './BrandProductPicker' import { Button, ErrorText, Field, Input, Textarea } from './ui' type CreatePlacementTopicDialogProps = { open: boolean + brands: BrandData[] onClose: () => void onCreated: (data: CreatePlacementTopicData) => void } -export function CreatePlacementTopicDialog({ open, onClose, onCreated }: CreatePlacementTopicDialogProps) { - const [displayName, setDisplayName] = useState('') +export function CreatePlacementTopicDialog({ + open, + brands, + onClose, + onCreated, +}: CreatePlacementTopicDialogProps) { + const [brandId, setBrandId] = useState('') + const [productId, setProductId] = useState(null) + const [topicName, setTopicName] = useState('') const [seedQuery, setSeedQuery] = useState('') const [brief, setBrief] = useState('') - const [productContext, setProductContext] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState('') + const catalogBrands = useMemo(() => brands.filter(isCatalogBrand), [brands]) + + const selectedBrand = useMemo( + () => catalogBrands.find((item) => item.id === brandId) ?? null, + [brandId, catalogBrands], + ) + useEffect(() => { if (!open) return - setDisplayName('') + const preferred = + catalogBrands.find((item) => item.id === getActiveBrandId())?.id || + catalogBrands.find((item) => brandHasCatalogProducts(item))?.id || + catalogBrands[0]?.id || + '' + setBrandId(preferred) + setProductId(null) + setTopicName('') setSeedQuery('') setBrief('') - setProductContext('') setError('') - }, [open]) + }, [open, catalogBrands]) + + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', onKey) + document.body.style.overflow = '' + } + }, [open, onClose]) if (!open) return null const submit = async () => { - if (!displayName.trim() || !seedQuery.trim() || !brief.trim()) { - setError('請填寫主題名稱、種子關鍵字與主題目標') + if (!brandId) { + setError('請選擇要置入的品牌') return } - if (!hasProductContext(productContext)) { - setError('請填寫想置入的品牌與產品') + if (!brandHasCatalogProducts(selectedBrand)) { + setError('請先到品牌庫為此品牌新增至少一個產品') + return + } + if (!topicName.trim()) { + setError('請填寫主題名稱') + return + } + if (!seedQuery.trim() || !brief.trim()) { + setError('請填寫種子關鍵字與主題目標') + return + } + if (!selectedBrand) { + setError('請選擇要置入的品牌') return } setSubmitting(true) setError('') try { - const data = await api.post( - '/api/v1/brands/placement-topics', + const topic = await api.post( + '/api/v1/placement/topics/', { - display_name: displayName.trim(), + brand_id: brandId, + topic_name: topicName.trim(), seed_query: seedQuery.trim(), brief: brief.trim(), - product_context: productContext, + product_id: productId ?? '', }, { auth: true }, ) - onCreated(data) + + const job = await api.post( + `/api/v1/placement/topics/${encodeURIComponent(topic.id)}/knowledge-graph/expand`, + { seed_query: seedQuery.trim(), regenerate_map: true }, + { auth: true }, + ) + onCreated({ + topic, + job_id: job.job_id, + status: job.status, + message: job.message, + }) onClose() } catch (e) { setError(e instanceof ApiError ? e.message : '建立失敗') @@ -62,51 +124,94 @@ export function CreatePlacementTopicDialog({ open, onClose, onCreated }: CreateP } return ( -
-
-

新增找 TA 主題

-

- 填寫主題目標與置入產品後,系統會自動產生研究地圖並擴展知識圖譜。 -

- -
- - setDisplayName(e.target.value)} - placeholder="例:敏感肌保養置入" - data-islander-label="主題名稱" - /> - - - setSeedQuery(e.target.value)} - placeholder="例:敏感肌、換季泛紅" - data-islander-label="種子關鍵字" - /> - - -