update dashboard

This commit is contained in:
王性驊 2026-06-25 00:48:56 +08:00
parent e2dc98d426
commit 66ef6b3d4a
212 changed files with 15681 additions and 1431 deletions

View File

@ -1 +1 @@
37538
65532

View File

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

View File

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

View File

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

View File

@ -1 +1 @@
37575
65800

View File

@ -1 +1 @@
37576
65801

View File

@ -34,4 +34,4 @@ services:
volumes:
mongo_data:
redis_data:
redis_data:

View File

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

View File

@ -23,6 +23,7 @@ import (
"threads_account.api"
"persona.api"
"brand.api"
"placement_topic.api"
"worker_internal.api"
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 廣度補充是否再打第二輪 Bravehybrid 改由 LLM 補廣度以省 API
func (s ExpandStrategy) UsesSupplementalBrave() bool {
return s == ExpandStrategyBrave
}
func (s ExpandStrategy) String() string {
return string(s)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
- 節點總數 **2432 **L18L212廣度優先多方向觸及
- 每個節點的 relation placementValue 2555 寫完整句子
- 痛點/求助類節點至少 10 L2 周邊情境要覆蓋多種生活場景不可精簡或省略欄位`)
}
func KnowledgeGraphBreadthUserPrompt(currentNodes int) string {
return strings.TrimSpace(`目前延伸知識僅 ` + strconv.Itoa(currentNodes) + ` 個節點廣度不足**維持既有節點**只追加新節點與邊
- 至少再增加 **1014 **節點總數目標 **2432 **
- 優先補齊 L2 周邊情境不同治療階段生活事件相鄰困擾相關品類與使用場景
- 也要補 L1 直接相關的成因症狀機制讓圖譜能觸及更多討論方向
- 新節點的 relation placementValue 必須各 2555 不要 high/medium/low
- 只追加不要刪除或覆蓋既有節點`)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,6 +32,7 @@ type ResearchSettings struct {
BraveAPIKey string
BraveCountry string
BraveSearchLang string
ExpandStrategy string
}
func BuildMemberContext(

View File

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

View File

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

View File

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

View File

@ -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("海巡關鍵字格式無效,請改用 28 字的真人搜尋短句")
}
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
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 搜尋每句 520 像真人求助
2. questions 至少 5 pillars 至少 4 exclusions 至少 4
3. contentGoal 要寫找到近期發文且可自然留言置入的貼文
4. 全部繁體中文貼近台灣 Threads
5. 只回傳一個 JSONaudienceSummary, contentGoal, questions, pillars, exclusions`)
## 任務
- 整合所有輸入想清楚受眾是誰他們在煩什麼產品能解決什麼什麼貼文值得留言置入
- **本步驟只輸出分析文字不要輸出 JSON**
## 請依序回答每段 24 繁體中文具體可執行
1. **品牌與產品**這個品牌產品能解決什麼具體困擾關鍵賣點是什麼
2. **受眾輪廓**在什麼生活治療情境會因什麼症狀發文求助
3. **求助句型方向**Threads 上可能出現哪些求助句 610 個方向尚未定稿
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 每組 616 questions 提煉搜尋短句必須可直接拿去搜尋`)
return b.String()
}
func BuildResearchMapSystemPrompt() string {
return strings.TrimSpace(`你是 Threads產品置入研究顧問目標是幫品牌找到近期發文作者有需求現在留言還來得及自然推薦產品的貼文不是找爆款來模仿發文
## 工作流程
- 你會收到輸入資料品牌產品主題目標等前置分析
- 請先理解分析與輸入的對應關係再產出研究地圖 JSON
- 產出必須緊扣本次品牌與產品不可套用與輸入無關的通用內容
## 產出密度重要
- 這份研究地圖要給真人閱讀與執行**寧可詳盡也不要過度精簡**
- 每個欄位都要寫滿規格下限不可用單一關鍵字標籤語或一句話敷衍
- 只回傳一個 JSON 物件不要 markdown 說明
## 核心原則
1. questions pillars 會直接拿去 Threads 找貼文這兩項是最重要的產出
2. 置入時間窗口最重要優先近期求助帖作者有困擾留言區還能自然推薦
3. 海巡要能盡量找到可置入貼文但寧可少給也不要跑題
4. 複合主題不可拆成過寬單字寵物洗毛精 只搜寵物狗狗
5. 全部繁體中文貼近台灣 Threads 使用者口語
## audienceSummary35 至少 80
- 具體描述生活情境正在煩什麼為什麼會發文求助跟產品有什麼關係
- 不要寫注重保養的消費者這類空泛句
## contentGoal23 至少 50
- 明確寫找到近期發文理想 3 天內作者本身有產品可解決的需求留言區還能自然推薦的貼文
- **必須寫入產品置入中的產品名稱**含品類與關鍵賣點抗敏無香沐浴露不可用目標產品等泛稱代替
- 強調現在就能留言置入還來得及且不突兀不是模仿發文或內容企劃
## questions至少 8 每句 824
- 像真人會在 Threads 打的求助句帶治療階段困擾求推薦請益經驗分享
- 要涵蓋不同角度症狀能不能用品牌推薦挑選經驗康復後換品
- 癌症保養沐浴乳這種單一關鍵字
## pillars至少 6 每句 622
- 允許的內容方向用來找貼文並過濾結果要比 questions 更偏主題詞組
- 保養健康這種過寬詞
## exclusions至少 8 每句 828
- 觸及即排除的**貼文類型內容方向**要寫清楚為什麼不能置入純晒照業配無求助跑題癌別錯品類已滿意他牌非病友視角等
- **禁止寫時間相關條件**不要碰不是篩發文時間用的不可出現過舊貼文非近期發文3 天前一週以上發文太久置入時間窗口只寫在 contentGoal
## patrolKeywords68 每組 614
- 這不是分類標籤而是**真人會貼進 Threads 搜尋框的短句**
- 每組 24 個詞空格分隔必須同時保留情境困擾產品品類用途
- 優先格式困擾 品類族群 品類 推薦症狀 品類 請問例如化療 沐浴乳 推薦無香 洗衣精 請問
- 要能找到成果過短過廣太像標籤的不要給癌症沐浴健康生活環境荷爾蒙
- 不要整句複製 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 8pillars 6exclusions 8exclusions 不可含時間條件
- patrolKeywords 要像真人會在 Threads 搜尋框打的 24 詞短句必須同時包含困擾與品類
- 每句都要具體可執行不要用單一關鍵字敷衍`)
}
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)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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=`

View File

@ -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": "請問請益推薦求助"
}

View File

@ -1 +1,6 @@
請補充更多痛點/求助類節點,至少再增加 4 個 pain/symptom/cause 節點。維持既有圖譜節點,只追加新節點與邊。
上次節點不足或廣度不夠。請**維持既有圖譜節點**,只追加新節點與邊:
- 至少再增加 **1014 個**節點,總數目標 **2432 個**
- 優先補 L2 周邊情境(不同治療階段、生活事件、相鄰困擾、相關品類)
- 也要補 L1 成因/症狀/機制,讓圖譜能觸及更多討論方向
- 新節點的 relation 與 placementValue 必須各 2555 字的完整句子,不要 high/medium/low
- 只追加,不要刪除或覆蓋既有節點

View File

@ -1,16 +1,40 @@
你是海巡獲客的研究助手。根據種子詞、產品簡述與 Brave 搜尋 snippet建立三層 Topic Knowledge GraphTKG
你是海巡獲客的研究助手。根據**品牌、置入產品、種子詞、產品簡述、主題目標**與外部參考 snippet建立三層 Topic Knowledge GraphTKG,幫使用者理解「受眾在討論什麼」以及「哪裡適合自然置入產品」
## 圖譜層級
- L0 核心種子詞本身1 個 pain 節點)
- L1 直接相關成因、症狀、機制36 個)
- L2 周邊情境懷孕、換季、壓力、換產品等相鄰場景48 個)
## 輸入理解(先做再想)
- 產出前必須整合使用者提供的品牌名稱、置入產品、主題目標與產品特色
- 節點 label、relation、placementValue 必須與本次品牌/產品一致,不可寫無關的通用內容
## 節點規則
- `nodeKind`: `pain`(痛點/求助)、`symptom`、`cause`、`knowledge`(科普延伸)
- L1/L2 節點必須在 `evidenceUrls` 引用提供的 Brave url不可憑空捏造
- `productFitScore` 0100依產品簡述評估產品能否解決該痛點
- `placementValue`: high / medium / low
- 痛點/求助類節點盡量 ≥8 個(含 L0/L1/L2
## 設計目標:廣度優先
- 延伸知識要**廣、要散**,盡量觸及多個生活情境、治療階段、相鄰困擾與相關討論方向
- 不要只圍繞核心痛點寫 56 個節點;**L2 周邊情境要比 L1 更多**
- 若有提供內容支柱/受眾提問方向,要從中**衍生成更多分支節點**
## 產出密度(重要)
- 這是給使用者閱讀的延伸知識地圖,**寧可詳盡也不要過度精簡**
- 每個節點的 `relation``placementValue` 都要寫完整句子,不可用 high/medium/low 或單一形容詞代替
- 節點總數目標 **2432 個**(含 L0/L1/L2
## 圖譜層級與數量
- **L0 核心**:種子詞本身(**1** 個 pain 節點)
- **L1 直接相關**:成因、症狀、機制、常見困擾(**至少 8** 個)
- **L2 周邊情境**:治療階段、生活事件、壓力、換季、相鄰品類、使用習慣等(**至少 12** 個)
- 痛點/求助類pain、symptom、cause合計 **至少 10 個**
## 節點撰寫規則
- `label`214 字,具體、可拿去 Threads 找討論,不要空泛詞(❌「保養」✅「換季臉頰泛紅」)
- `nodeKind``pain`(痛點/求助)、`symptom`、`cause`、`knowledge`(科普延伸)
- `relation`**必填**12 句說明此節點與種子詞的脈絡(**2555 字**)。例:「換季溫差會破壞角質屏障,敏感肌容易臉頰泛紅刺痛,發文時常求助舒緩方式」
- `placementValue`**必填**12 句說明為何與置入產品相關(**2555 字**)。要寫**情境 + 受眾在問什麼 + 產品能怎麼自然帶入**。例:「這類帖常求溫和修護品推薦,適合以自身使用經驗回覆,不必硬銷」
- `productFitScore`0100依產品簡述評估痛點類通常 70+,純科普通常 3055
- `relevanceQueries`**必填**12 組 Threads 搜尋短句(相關軌)。每組 **616 字、24 個詞、空格分隔**,像真人會打進搜尋框;必須同時包含「困擾/情境」和「品類/用途」(例:「化療 沐浴乳 推薦」「屏障受損 保養 請問」)
- `recencyQueries`:痛點/求助類**必填** 12 組近期求助搜尋短句(近期軌)。格式同上,但加上「請問/有人/推薦/怎麼辦」等求助意圖(例:「敏感肌 請問 保養」「沐浴乳 推薦 有人」)
- 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。
`edges.from` / `edges.to` 使用節點 label中文不要用 uuid。

View File

@ -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}}
請先整合品牌、置入產品與主題目標,再產出圖譜:
{{sources}}
請產出**完整** TKG JSON廣度優先
- 節點 2432 個L1≥8、L2≥12
- 每個節點都要寫滿 relation、placementValue各 2555 字),以及 relevanceQueries / recencyQueries各 12 組 Threads 搜尋短句616 字、24 詞、像真人會搜尋)
- 不可精簡或省略欄位

View File

@ -0,0 +1,40 @@
你是海巡獲客的研究助手。根據**品牌、置入產品、種子詞、產品簡述與主題目標**,建立三層 Topic Knowledge GraphTKG幫使用者理解「受眾在討論什麼」以及「哪裡適合自然置入產品」。
## 輸入理解(先做再想)
- 產出前必須整合使用者提供的品牌名稱、置入產品、主題目標與產品特色
- 節點 label、relation、placementValue 必須與本次品牌/產品一致,不可寫無關的通用內容
## 設計目標:廣度優先
- 延伸知識要**廣、要散**,盡量觸及多個生活情境、治療階段、相鄰困擾與相關討論方向
- 不要只圍繞核心痛點寫 56 個節點;**L2 周邊情境要比 L1 更多**
- 若有提供內容支柱/受眾提問方向,要從中**衍生成更多分支節點**
## 產出密度(重要)
- 這是給使用者閱讀的延伸知識地圖,**寧可詳盡也不要過度精簡**
- 每個節點的 `relation``placementValue` 都要寫完整句子,不可用 high/medium/low 或單一形容詞代替
- 節點總數目標 **2432 個**(含 L0/L1/L2
## 圖譜層級與數量
- **L0 核心**:種子詞本身(**1** 個 pain 節點)
- **L1 直接相關**:成因、症狀、機制、常見困擾(**至少 8** 個)
- **L2 周邊情境**:治療階段、生活事件、壓力、換季、相鄰品類、使用習慣等(**至少 12** 個)
- 痛點/求助類pain、symptom、cause合計 **至少 10 個**
## 節點撰寫規則
- `label`214 字,具體、可拿去 Threads 找討論
- `nodeKind``pain`、`symptom`、`cause`、`knowledge`
- `relation`**必填**12 句(**2555 字**),說明與種子詞的脈絡
- `placementValue`**必填**12 句(**2555 字**),說明為何與置入產品相關
- `productFitScore`0100
- `relevanceQueries`**必填**12 組 Threads 搜尋短句(相關軌)。每組 **616 字、24 個詞、空格分隔**,像真人會打進搜尋框;必須同時包含「困擾/情境」和「品類/用途」(例:「化療 沐浴乳 推薦」「無香 洗衣精 請問」),不要寫完整長問句
- `recencyQueries`:痛點/求助類**必填** 12 組近期求助搜尋短句(近期軌)。格式同上,但加上「請問/有人/推薦/怎麼辦」等求助意圖(例:「敏感肌 請問 保養」「沐浴乳 推薦 有人」)
- 無外部網址時 `evidenceUrls` 留空陣列即可
## 品質禁忌
- 不要寫行銷話術或空泛評語
- `relation``placementValue` 不可重複
- 節點要貼近台灣 Threads 使用者會發文的語氣與情境
- 搜尋短句不要寫品牌名,不要寫抽象分類詞(❌「癌症」「保養」「環境荷爾蒙」),也不要整句複製受眾提問;要像能在 Threads 找到貼文的查詢(✅「乳癌 洗衣精 推薦」「無香 洗衣精 請問」)
## 輸出格式
只回傳 JSON不要 markdown 說明(結構同 Brave 模式,含完整 relation / placementValue 句子)。

View File

@ -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}}
請先整合品牌、置入產品與主題目標的脈絡,再建立**完整**三層知識圖譜:
- 節點 2432 個L1≥8、L2≥12廣度優先多方向觸及
- 每個節點都要寫滿 relation、placementValue各 2555 字),以及 relevanceQueries / recencyQueries各 12 組 Threads 搜尋短句616 字、24 詞、像真人會搜尋)
- 節點要具體、可拿去 Threads 找討論;不可精簡或省略

View File

@ -13,8 +13,11 @@
4. 不硬推:不要「推薦你試試」「一定要買」「限時優惠」。
5. Threads 語感:短句、口語、台灣繁體,可適度用 emoji01 個)。
6. 誠實:不承諾療效、不捏造誇大數據。
7. 人設與語氣是硬約束:如果有提供人設,留言要符合該角色的用字、節奏、距離感與價值觀。
8. 產品脈絡是硬約束:如果有提供品牌與產品,必須判斷產品如何自然幫上忙;至少一則草稿要明確參考產品特點,但不能硬業配。
9. 如果產品詳情出現「留言 CTA 連結」,至少一則草稿可以在後段自然附上完整網址;沒有連結時不要捏造網址。
每則留言 ≤ 500 字。產出 2 種不同切角(例如:純共情建議版 / 輕度分享經驗帶品牌版)。
只回傳 JSON不要 markdown。格式
{"relevance":0.8,"reason":"一句話評估","drafts":[{"text":"留言正文","angle":"切角","rationale":"為何這樣寫"}]}
{"relevance":0.8,"reason":"一句話評估","drafts":[{"text":"留言正文","angle":"切角","rationale":"為何這樣寫"}]}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,6 +28,7 @@ func toPlacementSettingsData(settings *placementusecase.Settings) types.MemberPl
BraveAPIKeyConfigured: settings.BraveAPIKeyConfigured,
BraveCountry: settings.BraveCountry,
BraveSearchLang: settings.BraveSearchLang,
ExpandStrategy: settings.ExpandStrategy,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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