update dashboard
This commit is contained in:
parent
e2dc98d426
commit
66ef6b3d4a
|
|
@ -1 +1 @@
|
|||
37538
|
||||
65532
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
37575
|
||||
65800
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
37576
|
||||
65801
|
||||
|
|
|
|||
|
|
@ -34,4 +34,4 @@ services:
|
|||
|
||||
volumes:
|
||||
mongo_data:
|
||||
redis_data:
|
||||
redis_data:
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"threads_account.api"
|
||||
"persona.api"
|
||||
"brand.api"
|
||||
"placement_topic.api"
|
||||
"worker_internal.api"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,12 +34,14 @@ type (
|
|||
BraveAPIKeyConfigured bool `json:"brave_api_key_configured"`
|
||||
BraveCountry string `json:"brave_country"`
|
||||
BraveSearchLang string `json:"brave_search_lang"`
|
||||
ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
|
||||
}
|
||||
|
||||
UpdateMemberPlacementSettingsReq {
|
||||
BraveAPIKey *string `json:"brave_api_key,optional"`
|
||||
BraveCountry *string `json:"brave_country,optional"`
|
||||
BraveSearchLang *string `json:"brave_search_lang,optional"`
|
||||
ExpandStrategy *string `json:"expand_strategy,optional"`
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package knowledge
|
||||
|
||||
import "strings"
|
||||
|
||||
type ExpandStrategy string
|
||||
|
||||
const (
|
||||
ExpandStrategyBrave ExpandStrategy = "brave"
|
||||
ExpandStrategyLLM ExpandStrategy = "llm"
|
||||
ExpandStrategyHybrid ExpandStrategy = "hybrid"
|
||||
)
|
||||
|
||||
func ParseExpandStrategy(raw string) ExpandStrategy {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case string(ExpandStrategyLLM):
|
||||
return ExpandStrategyLLM
|
||||
case string(ExpandStrategyHybrid):
|
||||
return ExpandStrategyHybrid
|
||||
default:
|
||||
return ExpandStrategyBrave
|
||||
}
|
||||
}
|
||||
|
||||
func (s ExpandStrategy) RequiresBrave() bool {
|
||||
return s == ExpandStrategyBrave || s == ExpandStrategyHybrid
|
||||
}
|
||||
|
||||
// UsesSupplementalBrave 廣度補充是否再打第二輪 Brave(hybrid 改由 LLM 補廣度以省 API)。
|
||||
func (s ExpandStrategy) UsesSupplementalBrave() bool {
|
||||
return s == ExpandStrategyBrave
|
||||
}
|
||||
|
||||
func (s ExpandStrategy) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package knowledge
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
minGraphNodes = 18
|
||||
minLayer1Nodes = 6
|
||||
minLayer2Nodes = 10
|
||||
minBreadthNodes = 22
|
||||
targetGraphNodes = 28
|
||||
minNodeCopyRunes = 15
|
||||
)
|
||||
|
||||
func GraphTooThin(g Graph) bool {
|
||||
if len(g.Nodes) < minGraphNodes {
|
||||
return true
|
||||
}
|
||||
layerCounts := map[int]int{}
|
||||
thinCopy := 0
|
||||
for _, node := range g.Nodes {
|
||||
layerCounts[node.Layer]++
|
||||
if utf8.RuneCountInString(strings.TrimSpace(node.Relation)) < minNodeCopyRunes ||
|
||||
utf8.RuneCountInString(strings.TrimSpace(node.PlacementValue)) < minNodeCopyRunes {
|
||||
thinCopy++
|
||||
}
|
||||
}
|
||||
if layerCounts[1] < minLayer1Nodes || layerCounts[2] < minLayer2Nodes {
|
||||
return true
|
||||
}
|
||||
if thinCopy > len(g.Nodes)/2 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GraphNeedsBreadth(g Graph) bool {
|
||||
return len(g.Nodes) < minBreadthNodes
|
||||
}
|
||||
|
||||
func MinBreadthGraphNodes() int {
|
||||
return minBreadthNodes
|
||||
}
|
||||
|
||||
func KnowledgeGraphRetryUserPrompt() string {
|
||||
return strings.TrimSpace(`上次產出過於簡略或節點不足。請重新產出完整 TKG JSON:
|
||||
- 節點總數 **24~32 個**,L1≥8、L2≥12(廣度優先,多方向觸及)
|
||||
- 每個節點的 relation 與 placementValue 各 25~55 字,寫完整句子
|
||||
- 痛點/求助類節點至少 10 個;L2 周邊情境要覆蓋多種生活場景,不可精簡或省略欄位`)
|
||||
}
|
||||
|
||||
func KnowledgeGraphBreadthUserPrompt(currentNodes int) string {
|
||||
return strings.TrimSpace(`目前延伸知識僅 ` + strconv.Itoa(currentNodes) + ` 個節點,廣度不足。請**維持既有節點**,只追加新節點與邊:
|
||||
- 至少再增加 **10~14 個**節點,總數目標 **24~32 個**
|
||||
- 優先補齊 L2 周邊情境:不同治療階段、生活事件、相鄰困擾、相關品類與使用場景
|
||||
- 也要補 L1 直接相關的成因、症狀、機制,讓圖譜能觸及更多討論方向
|
||||
- 新節點的 relation 與 placementValue 必須各 25~55 字;不要 high/medium/low
|
||||
- 只追加,不要刪除或覆蓋既有節點`)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ type ResearchSettings struct {
|
|||
BraveAPIKey string
|
||||
BraveCountry string
|
||||
BraveSearchLang string
|
||||
ExpandStrategy string
|
||||
}
|
||||
|
||||
func BuildMemberContext(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
libbrave "haixun-backend/internal/library/brave"
|
||||
libkg "haixun-backend/internal/library/knowledge"
|
||||
|
|
@ -32,16 +33,19 @@ type ScanCandidate struct {
|
|||
EngagementScore int
|
||||
PlacementScore int
|
||||
SolvedByProduct bool
|
||||
PostedAt string
|
||||
Replies []ReplyCandidate
|
||||
}
|
||||
|
||||
type DualTrackInput struct {
|
||||
Nodes []libkg.Node
|
||||
Exclusions []string
|
||||
Member MemberContext
|
||||
Client *libbrave.Client
|
||||
Crawler CrawlerSearchFn
|
||||
Limit int // max queries budget; 0 = default
|
||||
Nodes []libkg.Node
|
||||
PatrolKeywords []string
|
||||
Exclusions []string
|
||||
Member MemberContext
|
||||
Client *libbrave.Client
|
||||
Crawler CrawlerSearchFn
|
||||
Limit int // max queries budget; 0 = default
|
||||
OnCheckpoint func(candidates []ScanCandidate) error
|
||||
}
|
||||
|
||||
type DualTrackProgress func(message string, pct int)
|
||||
|
|
@ -105,8 +109,11 @@ func CollectTagQueries(nodes []libkg.Node) []TagQuery {
|
|||
|
||||
// RunDualTrackDiscover executes relevance + recency queries and merges by permalink.
|
||||
func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) {
|
||||
queries := CollectTagQueries(input.Nodes)
|
||||
queries := ResolveTagQueries(input.Nodes, input.PatrolKeywords)
|
||||
if len(queries) == 0 {
|
||||
if len(input.PatrolKeywords) > 0 {
|
||||
return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句")
|
||||
}
|
||||
return nil, fmt.Errorf("沒有勾選的節點或可用 tag")
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +163,7 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
|||
Priority: priority,
|
||||
PlacementScore: computePlacementScore(post.Text, tq.ProductFitScore, tq.Dimension == QueryRecency),
|
||||
SolvedByProduct: tq.ProductFitScore >= 55,
|
||||
PostedAt: strings.TrimSpace(post.PostedAt),
|
||||
}
|
||||
order = append(order, key)
|
||||
continue
|
||||
|
|
@ -170,6 +178,9 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
|||
existing.ProductFitScore = tq.ProductFitScore
|
||||
existing.SolvedByProduct = tq.ProductFitScore >= 55
|
||||
}
|
||||
if strings.TrimSpace(existing.PostedAt) == "" && strings.TrimSpace(post.PostedAt) != "" {
|
||||
existing.PostedAt = strings.TrimSpace(post.PostedAt)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -187,25 +198,20 @@ func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress
|
|||
if err := runQuery(tq, limit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.OnCheckpoint != nil {
|
||||
snapshot := snapshotMergedCandidates(merged, order, false)
|
||||
if err := input.OnCheckpoint(snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if input.Member.AllowsCrawler && input.Member.DevMode && i < total-1 {
|
||||
if err := politeDiscoverPause(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]ScanCandidate, 0, len(order))
|
||||
for _, key := range order {
|
||||
item := merged[key]
|
||||
if item.HasRelevance && item.HasRecency && item.ProductFitScore >= 45 {
|
||||
item.Priority = "gold"
|
||||
} else if item.HasRecency {
|
||||
item.Priority = "recent"
|
||||
} else {
|
||||
item.Priority = "relevant"
|
||||
}
|
||||
if item.ProductFitScore < 30 && item.Priority != "gold" {
|
||||
continue
|
||||
}
|
||||
item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency)
|
||||
item.SolvedByProduct = item.ProductFitScore >= 55
|
||||
out = append(out, *item)
|
||||
}
|
||||
out := snapshotMergedCandidates(merged, order, true)
|
||||
if onProgress != nil {
|
||||
onProgress(fmt.Sprintf("合併完成,共 %d 篇候選貼文", len(out)), 90)
|
||||
}
|
||||
|
|
@ -272,6 +278,34 @@ func discoverViaBrave(ctx context.Context, client *libbrave.Client, member Membe
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool) []ScanCandidate {
|
||||
out := make([]ScanCandidate, 0, len(order))
|
||||
for _, key := range order {
|
||||
item := merged[key]
|
||||
finalizeScanCandidate(item)
|
||||
if applyFinalFilter && item.ProductFitScore < 30 && item.Priority != "gold" {
|
||||
continue
|
||||
}
|
||||
out = append(out, *item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func finalizeScanCandidate(item *ScanCandidate) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
if item.HasRelevance && item.HasRecency && item.ProductFitScore >= 45 {
|
||||
item.Priority = "gold"
|
||||
} else if item.HasRecency {
|
||||
item.Priority = "recent"
|
||||
} else {
|
||||
item.Priority = "relevant"
|
||||
}
|
||||
item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency)
|
||||
item.SolvedByProduct = item.ProductFitScore >= 55
|
||||
}
|
||||
|
||||
func computePlacementScore(text string, productFit int, recent bool) int {
|
||||
score := 30 + productFit/4
|
||||
if HasPlacementIntent(text) {
|
||||
|
|
@ -298,3 +332,15 @@ func max(a, b int) int {
|
|||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func politeDiscoverPause(ctx context.Context) error {
|
||||
wait := 2*time.Second + jitterDuration(2*time.Second)
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type ResearchMap struct {
|
||||
|
|
@ -13,46 +14,206 @@ type ResearchMap struct {
|
|||
Questions []string `json:"questions"`
|
||||
Pillars []string `json:"pillars"`
|
||||
Exclusions []string `json:"exclusions"`
|
||||
PatrolKeywords []string `json:"patrolKeywords"`
|
||||
}
|
||||
|
||||
type ResearchMapInput struct {
|
||||
Label string
|
||||
SeedQuery string
|
||||
Brief string
|
||||
ProductContext string
|
||||
BrandDisplayName string
|
||||
TopicName string
|
||||
SeedQuery string
|
||||
Brief string
|
||||
Goals string
|
||||
TargetAudience string
|
||||
ProductContext string
|
||||
ProductBrief string
|
||||
ProductLabel string
|
||||
}
|
||||
|
||||
const (
|
||||
minResearchQuestions = 8
|
||||
minResearchPillars = 6
|
||||
minResearchExclusions = 8
|
||||
minAudienceRunes = 80
|
||||
minContentGoalRunes = 50
|
||||
)
|
||||
|
||||
var researchMapFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
|
||||
|
||||
func BuildResearchMapSystemPrompt() string {
|
||||
return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。目標是幫品牌找到「近期發文、作者有需求、現在留言還來得及自然推薦產品」的貼文。
|
||||
func BuildResearchMapAnalysisSystemPrompt() string {
|
||||
return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。請先閱讀使用者提供的**品牌、置入產品、主題目標**等輸入,完成結構化分析。
|
||||
|
||||
規則:
|
||||
1. questions 與 pillars 會直接拿去 Threads 搜尋——每句 5~20 字,像真人求助
|
||||
2. questions 至少 5 個;pillars 至少 4 個;exclusions 至少 4 個
|
||||
3. contentGoal 要寫:找到近期發文且可自然留言置入的貼文
|
||||
4. 全部繁體中文,貼近台灣 Threads
|
||||
5. 只回傳一個 JSON:audienceSummary, contentGoal, questions, pillars, exclusions`)
|
||||
## 任務
|
||||
- 整合所有輸入,想清楚受眾是誰、他們在煩什麼、產品能解決什麼、什麼貼文值得留言置入
|
||||
- **本步驟只輸出分析文字,不要輸出 JSON**
|
||||
|
||||
## 請依序回答(每段 2~4 句,繁體中文,具體可執行)
|
||||
1. **品牌與產品**:這個品牌/產品能解決什麼具體困擾?關鍵賣點是什麼?
|
||||
2. **受眾輪廓**:誰、在什麼生活/治療情境、會因什麼症狀發文求助?
|
||||
3. **求助句型方向**:Threads 上可能出現哪些求助句(列 6~10 個方向,尚未定稿)
|
||||
4. **內容方向與排除**:適合找文的支柱主題、必須排除的貼文類型(排除項只寫內容類型,不寫發文時間)
|
||||
5. **置入策略**:contentGoal 應點名的完整產品名稱、近期發文窗口、留言置入時機
|
||||
|
||||
分析必須緊扣輸入資料,不可套用與品牌/產品/主題無關的通用範例。`)
|
||||
}
|
||||
|
||||
func BuildResearchMapUserPrompt(in ResearchMapInput) string {
|
||||
func BuildResearchMapAnalysisUserPrompt(in ResearchMapInput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("【主題名稱】")
|
||||
b.WriteString(strings.TrimSpace(in.Label))
|
||||
b.WriteString("\n【種子關鍵字】")
|
||||
b.WriteString(strings.TrimSpace(in.SeedQuery))
|
||||
b.WriteString("\n【這個主題想做什么】\n")
|
||||
b.WriteString(strings.TrimSpace(in.Brief))
|
||||
b.WriteString("\n【產品置入】\n")
|
||||
product := FormatProductContextForPrompt(in.ProductContext)
|
||||
if product == "" {
|
||||
product = "(尚未填寫)"
|
||||
}
|
||||
b.WriteString(product)
|
||||
b.WriteString("\n\n請產出研究地圖 JSON。")
|
||||
b.WriteString("以下為本次置入主題的完整輸入,請先分析:\n\n")
|
||||
PlacementTopicContext{
|
||||
BrandDisplayName: in.BrandDisplayName,
|
||||
TopicName: in.TopicName,
|
||||
SeedQuery: in.SeedQuery,
|
||||
Brief: in.Brief,
|
||||
Goals: in.Goals,
|
||||
TargetAudience: in.TargetAudience,
|
||||
ProductContext: in.ProductContext,
|
||||
ProductBrief: in.ProductBrief,
|
||||
ProductLabel: in.ProductLabel,
|
||||
}.WritePromptBlock(&b)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func BuildResearchMapFinalizeUserPrompt(in ResearchMapInput, analysis string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("【輸入資料】\n")
|
||||
PlacementTopicContext{
|
||||
BrandDisplayName: in.BrandDisplayName,
|
||||
TopicName: in.TopicName,
|
||||
SeedQuery: in.SeedQuery,
|
||||
Brief: in.Brief,
|
||||
Goals: in.Goals,
|
||||
TargetAudience: in.TargetAudience,
|
||||
ProductContext: in.ProductContext,
|
||||
ProductBrief: in.ProductBrief,
|
||||
ProductLabel: in.ProductLabel,
|
||||
}.WritePromptBlock(&b)
|
||||
analysis = strings.TrimSpace(analysis)
|
||||
if analysis != "" {
|
||||
b.WriteString("\n【前置分析】\n")
|
||||
b.WriteString(analysis)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(`
|
||||
---
|
||||
|
||||
請依【前置分析】與【輸入資料】產出**完整**研究地圖 JSON(不可精簡),密度請對齊系統 prompt 中的優秀範例:
|
||||
1. 每個欄位必須反映本次品牌、產品與使用者輸入,不可照搬範例的主題用字
|
||||
2. audienceSummary 寫清楚「誰、治療/生活情境、具體困擾、在找什麼產品特徵」
|
||||
3. contentGoal 要寫入【產品詳情】的完整產品名稱、近期發文、留言置入時機
|
||||
4. questions 至少 8 句、pillars 至少 6 句、exclusions 至少 8 句(exclusions 只寫貼文類型,**不要寫時間/近期/幾天內**)
|
||||
5. questions 是「人會發文的求助句」;patrolKeywords 是「人會打進 Threads 搜尋框的短句」,兩者不可混在一起
|
||||
6. patrolKeywords 至少 6 組、最多 8 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func BuildResearchMapSystemPrompt() string {
|
||||
return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。目標是幫品牌找到「近期發文、作者有需求、現在留言還來得及自然推薦產品」的貼文——不是找爆款來模仿發文。
|
||||
|
||||
## 工作流程
|
||||
- 你會收到【輸入資料】(品牌、產品、主題目標等)與【前置分析】
|
||||
- 請先理解分析與輸入的對應關係,再產出研究地圖 JSON
|
||||
- 產出必須緊扣本次品牌與產品,不可套用與輸入無關的通用內容
|
||||
|
||||
## 產出密度(重要)
|
||||
- 這份研究地圖要給真人閱讀與執行,**寧可詳盡也不要過度精簡**
|
||||
- 每個欄位都要寫滿規格下限;不可用單一關鍵字、標籤語或一句話敷衍
|
||||
- 只回傳一個 JSON 物件,不要 markdown 說明
|
||||
|
||||
## 核心原則
|
||||
1. questions 與 pillars 會直接拿去 Threads 找貼文——這兩項是最重要的產出
|
||||
2. 置入時間窗口最重要:優先「近期」求助帖,作者有困擾、留言區還能自然推薦
|
||||
3. 海巡要能盡量找到可置入貼文,但寧可少給也不要跑題
|
||||
4. 複合主題不可拆成過寬單字(寵物洗毛精 ≠ 只搜「寵物」「狗狗」)
|
||||
5. 全部繁體中文,貼近台灣 Threads 使用者口語
|
||||
|
||||
## audienceSummary(3~5 句,至少 80 字)
|
||||
- 具體描述:誰、生活情境、正在煩什麼、為什麼會發文求助、跟產品有什麼關係
|
||||
- 不要寫「注重保養的消費者」這類空泛句
|
||||
|
||||
## contentGoal(2~3 句,至少 50 字)
|
||||
- 明確寫:找到「近期發文(理想 3 天內)」、作者本身有產品可解決的需求、留言區還能自然推薦的貼文
|
||||
- **必須寫入【產品置入】中的產品名稱**(含品類與關鍵賣點,如「抗敏無香沐浴露」),不可用「目標產品」等泛稱代替
|
||||
- 強調「現在就能留言置入還來得及且不突兀」,不是模仿發文或內容企劃
|
||||
|
||||
## questions(至少 8 個,每句 8~24 字)
|
||||
- 像真人會在 Threads 打的求助句,帶治療階段、困擾、求推薦、請益、經驗分享
|
||||
- 要涵蓋不同角度(症狀、能不能用、品牌推薦、挑選經驗、康復後換品)
|
||||
- 差:「癌症保養」「沐浴乳」這種單一關鍵字
|
||||
|
||||
## pillars(至少 6 個,每句 6~22 字)
|
||||
- 允許的內容方向,用來找貼文並過濾結果;要比 questions 更偏主題詞組
|
||||
- 差:「保養」「健康」這種過寬詞
|
||||
|
||||
## exclusions(至少 8 個,每句 8~28 字)
|
||||
- 觸及即排除的**貼文類型/內容方向**;要寫清楚「為什麼不能置入」(純晒照、業配、無求助、跑題癌別、錯品類、已滿意他牌、非病友視角等)
|
||||
- **禁止寫時間相關條件**:不要碰不是篩發文時間用的。不可出現「過舊貼文」「非近期發文」「3 天前」「一週以上」「發文太久」等——置入時間窗口只寫在 contentGoal
|
||||
|
||||
## patrolKeywords(6~8 組,每組 6~14 字)
|
||||
- 這不是分類標籤,而是**真人會貼進 Threads 搜尋框的短句**
|
||||
- 每組 2~4 個詞,空格分隔;必須同時保留「情境/困擾」與「產品品類/用途」
|
||||
- 優先格式:「困擾 品類」、「族群 品類 推薦」、「症狀 品類 請問」,例如「化療 沐浴乳 推薦」「無香 洗衣精 請問」
|
||||
- 要能找到成果:過短、過廣、太像標籤的不要給(差:「癌症」「沐浴」「健康生活」「環境荷爾蒙」)
|
||||
- 不要整句複製 questions,也不要寫品牌名;搜尋句要像使用者會查「有沒有人在討論」的字
|
||||
|
||||
## 優秀範例(請接近此密度、具體度與欄位長度)
|
||||
{
|
||||
"audienceSummary": "因荷爾蒙相關癌症(如乳癌、婦科癌症)正在治療中或康復後,對香精與化學成分特別敏感,洗澡或清潔時容易因香味而噁心、頭痛或皮膚不適,因此積極尋找「完全無香、抗敏、有第三方認證」沐浴與清潔產品的人。",
|
||||
"contentGoal": "找出「近期發文(理想 3 天內)」、作者本身因癌症或荷爾蒙治療導致對香味/化學成分敏感、有明確換沐浴乳或清潔產品需求、現在留言區自然推薦 ecostore 抗敏無香沐浴露還來得及且不突兀的貼文。",
|
||||
"questions": [
|
||||
"化療後皮膚敏感要換什麼沐浴乳",
|
||||
"乳癌治療中不能用有香味的沐浴乳嗎",
|
||||
"癌症病人適合用的無香沐浴乳推薦",
|
||||
"荷爾蒙治療皮膚乾癢怎麼挑沐浴乳",
|
||||
"打標靶後對香味很敏感怎麼辦",
|
||||
"康復後不想再用有香精的清潔用品",
|
||||
"癌症病友都用什麼牌子沐浴乳",
|
||||
"化療期間沐浴乳挑選經驗分享"
|
||||
],
|
||||
"pillars": [
|
||||
"化療皮膚敏感無香沐浴乳",
|
||||
"乳癌病友沐浴用品挑選",
|
||||
"荷爾蒙治療對香味敏感",
|
||||
"癌症康復後換清潔品牌",
|
||||
"抗敏無香沐浴乳推薦",
|
||||
"化療期間皮膚照護"
|
||||
],
|
||||
"exclusions": [
|
||||
"純曬照、純分享日常生活的貼文",
|
||||
"沒有求助、沒有換產品需求的閒聊",
|
||||
"只談癌症治療、確診心情但未提及清潔/沐浴困擾",
|
||||
"推廣其他品牌沐浴乳或業配他牌的貼文",
|
||||
"男性攝護腺癌、肺癌等與香味敏感較無直接相關的癌別(除非明確提到化學敏感)",
|
||||
"寵物洗毛精、洗碗精等其他品類",
|
||||
"已使用 ecostore 或他牌抗敏沐浴乳且滿意、無換品牌需求的貼文",
|
||||
"醫療專業衛教、營養師發文等非病友視角貼文"
|
||||
],
|
||||
"patrolKeywords": [
|
||||
"化療 沐浴乳 推薦",
|
||||
"無香沐浴乳 請問",
|
||||
"乳癌 沐浴乳 推薦",
|
||||
"荷爾蒙 敏感 沐浴乳",
|
||||
"化療 皮膚 沐浴乳",
|
||||
"癌症 無香 沐浴乳",
|
||||
"病友 沐浴乳 推薦",
|
||||
"抗敏 沐浴乳 推薦"
|
||||
]
|
||||
}`)
|
||||
}
|
||||
|
||||
// BuildResearchMapUserPrompt is kept for tests; production uses analysis + finalize prompts.
|
||||
func BuildResearchMapUserPrompt(in ResearchMapInput) string {
|
||||
return BuildResearchMapFinalizeUserPrompt(in, "")
|
||||
}
|
||||
|
||||
func ResearchMapRetryUserPrompt() string {
|
||||
return strings.TrimSpace(`上次產出過於簡略或項目不足。請重新產出完整 JSON,密度對齊系統 prompt 優秀範例:
|
||||
- 必須緊扣【輸入資料】中的品牌、產品與主題目標,不可照搬範例用字
|
||||
- audienceSummary ≥80 字,contentGoal ≥50 字(contentGoal 要寫入完整產品名稱)
|
||||
- questions ≥8、pillars ≥6、exclusions ≥8(exclusions 不可含時間條件)
|
||||
- patrolKeywords 要像真人會在 Threads 搜尋框打的 2~4 詞短句,必須同時包含困擾與品類
|
||||
- 每句都要具體、可執行,不要用單一關鍵字敷衍`)
|
||||
}
|
||||
|
||||
func ParseResearchMapOutput(raw string) (ResearchMap, error) {
|
||||
payload, err := extractResearchJSONObject(raw)
|
||||
if err != nil {
|
||||
|
|
@ -66,13 +227,49 @@ func ParseResearchMapOutput(raw string) (ResearchMap, error) {
|
|||
out.ContentGoal = strings.TrimSpace(out.ContentGoal)
|
||||
out.Questions = cleanStringList(out.Questions)
|
||||
out.Pillars = cleanStringList(out.Pillars)
|
||||
out.Exclusions = cleanStringList(out.Exclusions)
|
||||
out.Exclusions = filterTimeExclusions(cleanStringList(out.Exclusions))
|
||||
out.PatrolKeywords = cleanStringList(out.PatrolKeywords)
|
||||
if out.AudienceSummary == "" && len(out.Questions) == 0 {
|
||||
return ResearchMap{}, fmt.Errorf("research map missing audience or questions")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ResearchMapTooThin(m ResearchMap) bool {
|
||||
if utf8.RuneCountInString(m.AudienceSummary) < minAudienceRunes {
|
||||
return true
|
||||
}
|
||||
if utf8.RuneCountInString(strings.TrimSpace(m.ContentGoal)) < minContentGoalRunes {
|
||||
return true
|
||||
}
|
||||
if len(m.Questions) < minResearchQuestions {
|
||||
return true
|
||||
}
|
||||
if len(m.Pillars) < minResearchPillars {
|
||||
return true
|
||||
}
|
||||
if len(m.Exclusions) < minResearchExclusions {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var exclusionTimePattern = regexp.MustCompile(`近期|過舊|太久|天內|天前|幾天|一週|上週|發文時間|非近期|多久以前|月份前|昨天|今天發文|時間窗口|理想\s*\d`)
|
||||
|
||||
func filterTimeExclusions(items []string) []string {
|
||||
if len(items) == 0 {
|
||||
return items
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if exclusionTimePattern.MatchString(item) {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cleanStringList(items []string) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
seen := map[string]struct{}{}
|
||||
|
|
@ -102,3 +299,10 @@ func extractResearchJSONObject(raw string) ([]byte, error) {
|
|||
}
|
||||
return []byte(raw[start : end+1]), nil
|
||||
}
|
||||
|
||||
func ResearchMapTopicLabel(brandDisplayName, topicName string) string {
|
||||
if topic := strings.TrimSpace(topicName); topic != "" {
|
||||
return topic
|
||||
}
|
||||
return strings.TrimSpace(brandDisplayName)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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=`
|
||||
|
|
@ -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": "請問請益推薦求助"
|
||||
}
|
||||
|
|
@ -1 +1,6 @@
|
|||
請補充更多痛點/求助類節點,至少再增加 4 個 pain/symptom/cause 節點。維持既有圖譜節點,只追加新節點與邊。
|
||||
上次節點不足或廣度不夠。請**維持既有圖譜節點**,只追加新節點與邊:
|
||||
- 至少再增加 **10~14 個**節點,總數目標 **24~32 個**
|
||||
- 優先補 L2 周邊情境(不同治療階段、生活事件、相鄰困擾、相關品類)
|
||||
- 也要補 L1 成因/症狀/機制,讓圖譜能觸及更多討論方向
|
||||
- 新節點的 relation 與 placementValue 必須各 25~55 字的完整句子,不要 high/medium/low
|
||||
- 只追加,不要刪除或覆蓋既有節點
|
||||
|
|
@ -1,16 +1,40 @@
|
|||
你是海巡獲客的研究助手。根據種子詞、產品簡述與 Brave 搜尋 snippet,建立三層 Topic Knowledge Graph(TKG)。
|
||||
你是海巡獲客的研究助手。根據**品牌、置入產品、種子詞、產品簡述、主題目標**與外部參考 snippet,建立三層 Topic Knowledge Graph(TKG),幫使用者理解「受眾在討論什麼」以及「哪裡適合自然置入產品」。
|
||||
|
||||
## 圖譜層級
|
||||
- L0 核心:種子詞本身(1 個 pain 節點)
|
||||
- L1 直接相關:成因、症狀、機制(3–6 個)
|
||||
- L2 周邊情境:懷孕、換季、壓力、換產品等相鄰場景(4–8 個)
|
||||
## 輸入理解(先做再想)
|
||||
- 產出前必須整合使用者提供的品牌名稱、置入產品、主題目標與產品特色
|
||||
- 節點 label、relation、placementValue 必須與本次品牌/產品一致,不可寫無關的通用內容
|
||||
|
||||
## 節點規則
|
||||
- `nodeKind`: `pain`(痛點/求助)、`symptom`、`cause`、`knowledge`(科普延伸)
|
||||
- L1/L2 節點必須在 `evidenceUrls` 引用提供的 Brave url(不可憑空捏造)
|
||||
- `productFitScore` 0–100:依產品簡述評估產品能否解決該痛點
|
||||
- `placementValue`: high / medium / low
|
||||
- 痛點/求助類節點盡量 ≥8 個(含 L0/L1/L2)
|
||||
## 設計目標:廣度優先
|
||||
- 延伸知識要**廣、要散**,盡量觸及多個生活情境、治療階段、相鄰困擾與相關討論方向
|
||||
- 不要只圍繞核心痛點寫 5~6 個節點;**L2 周邊情境要比 L1 更多**
|
||||
- 若有提供內容支柱/受眾提問方向,要從中**衍生成更多分支節點**
|
||||
|
||||
## 產出密度(重要)
|
||||
- 這是給使用者閱讀的延伸知識地圖,**寧可詳盡也不要過度精簡**
|
||||
- 每個節點的 `relation` 與 `placementValue` 都要寫完整句子,不可用 high/medium/low 或單一形容詞代替
|
||||
- 節點總數目標 **24~32 個**(含 L0/L1/L2)
|
||||
|
||||
## 圖譜層級與數量
|
||||
- **L0 核心**:種子詞本身(**1** 個 pain 節點)
|
||||
- **L1 直接相關**:成因、症狀、機制、常見困擾(**至少 8** 個)
|
||||
- **L2 周邊情境**:治療階段、生活事件、壓力、換季、相鄰品類、使用習慣等(**至少 12** 個)
|
||||
- 痛點/求助類(pain、symptom、cause)合計 **至少 10 個**
|
||||
|
||||
## 節點撰寫規則
|
||||
- `label`:2~14 字,具體、可拿去 Threads 找討論,不要空泛詞(❌「保養」✅「換季臉頰泛紅」)
|
||||
- `nodeKind`:`pain`(痛點/求助)、`symptom`、`cause`、`knowledge`(科普延伸)
|
||||
- `relation`:**必填**,1~2 句說明此節點與種子詞的脈絡(**25~55 字**)。例:「換季溫差會破壞角質屏障,敏感肌容易臉頰泛紅刺痛,發文時常求助舒緩方式」
|
||||
- `placementValue`:**必填**,1~2 句說明為何與置入產品相關(**25~55 字**)。要寫**情境 + 受眾在問什麼 + 產品能怎麼自然帶入**。例:「這類帖常求溫和修護品推薦,適合以自身使用經驗回覆,不必硬銷」
|
||||
- `productFitScore`:0–100,依產品簡述評估(痛點類通常 70+,純科普通常 30–55)
|
||||
- `relevanceQueries`:**必填**,1~2 組 Threads 搜尋短句(相關軌)。每組 **6~16 字、2~4 個詞、空格分隔**,像真人會打進搜尋框;必須同時包含「困擾/情境」和「品類/用途」(例:「化療 沐浴乳 推薦」「屏障受損 保養 請問」)
|
||||
- `recencyQueries`:痛點/求助類**必填** 1~2 組近期求助搜尋短句(近期軌)。格式同上,但加上「請問/有人/推薦/怎麼辦」等求助意圖(例:「敏感肌 請問 保養」「沐浴乳 推薦 有人」)
|
||||
- L1/L2 節點必須在 `evidenceUrls` 引用下方提供的參考 url(不可捏造網址)
|
||||
|
||||
## 品質禁忌
|
||||
- 不要寫行銷話術、不要寫「高相關」「值得關注」這類空話
|
||||
- `relation` 與 `placementValue` 不可重複同一句話
|
||||
- 不要為了省字只列標籤;每個節點都要能獨立讀懂
|
||||
- 搜尋短句不要寫品牌名,不要寫抽象分類詞(❌「癌症」「保養」「環境荷爾蒙」),也不要整句複製受眾提問;要像能在 Threads 找到貼文的查詢(✅「乳癌 洗衣精 推薦」「無香 洗衣精 請問」)
|
||||
|
||||
## 輸出格式
|
||||
只回傳 JSON,不要 markdown 說明:
|
||||
|
|
@ -23,9 +47,24 @@
|
|||
"nodeKind": "pain",
|
||||
"type": "core",
|
||||
"layer": 0,
|
||||
"placementValue": "high",
|
||||
"relation": "種子主題:使用者因膚況不穩、換季或換產品後容易不適而主動求助",
|
||||
"placementValue": "核心痛點討論串最常求推薦與真實心得,適合以溫和修護產品的實際使用經驗自然回覆",
|
||||
"productFitScore": 95,
|
||||
"relevanceQueries": ["敏感肌 保養 推薦", "敏感肌 泛紅 怎麼辦"],
|
||||
"recencyQueries": ["敏感肌 請問 保養"],
|
||||
"evidenceUrls": []
|
||||
},
|
||||
{
|
||||
"label": "屏障受損",
|
||||
"nodeKind": "cause",
|
||||
"type": "cause",
|
||||
"layer": 1,
|
||||
"relation": "過度清潔、酸類疊加或環境刺激會讓屏障變薄,敏感肌泛紅刺痛往往由此而來",
|
||||
"placementValue": "求助帖會問怎麼救急與日常保養,可帶入低刺激、強調修護的產品使用脈絡",
|
||||
"productFitScore": 88,
|
||||
"relevanceQueries": ["屏障受損 保養 請問", "皮膚屏障 保養 推薦"],
|
||||
"recencyQueries": ["屏障受損 有人 推薦"],
|
||||
"evidenceUrls": ["https://example.com/article"]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
|
|
@ -34,4 +73,4 @@
|
|||
}
|
||||
```
|
||||
|
||||
`edges.from` / `edges.to` 使用節點 label(中文),不要用 uuid。
|
||||
`edges.from` / `edges.to` 使用節點 label(中文),不要用 uuid。
|
||||
|
|
|
|||
|
|
@ -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(廣度優先):
|
||||
- 節點 24~32 個,L1≥8、L2≥12
|
||||
- 每個節點都要寫滿 relation、placementValue(各 25~55 字),以及 relevanceQueries / recencyQueries(各 1~2 組 Threads 搜尋短句,6~16 字、2~4 詞、像真人會搜尋)
|
||||
- 不可精簡或省略欄位
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
你是海巡獲客的研究助手。根據**品牌、置入產品、種子詞、產品簡述與主題目標**,建立三層 Topic Knowledge Graph(TKG),幫使用者理解「受眾在討論什麼」以及「哪裡適合自然置入產品」。
|
||||
|
||||
## 輸入理解(先做再想)
|
||||
- 產出前必須整合使用者提供的品牌名稱、置入產品、主題目標與產品特色
|
||||
- 節點 label、relation、placementValue 必須與本次品牌/產品一致,不可寫無關的通用內容
|
||||
|
||||
## 設計目標:廣度優先
|
||||
- 延伸知識要**廣、要散**,盡量觸及多個生活情境、治療階段、相鄰困擾與相關討論方向
|
||||
- 不要只圍繞核心痛點寫 5~6 個節點;**L2 周邊情境要比 L1 更多**
|
||||
- 若有提供內容支柱/受眾提問方向,要從中**衍生成更多分支節點**
|
||||
|
||||
## 產出密度(重要)
|
||||
- 這是給使用者閱讀的延伸知識地圖,**寧可詳盡也不要過度精簡**
|
||||
- 每個節點的 `relation` 與 `placementValue` 都要寫完整句子,不可用 high/medium/low 或單一形容詞代替
|
||||
- 節點總數目標 **24~32 個**(含 L0/L1/L2)
|
||||
|
||||
## 圖譜層級與數量
|
||||
- **L0 核心**:種子詞本身(**1** 個 pain 節點)
|
||||
- **L1 直接相關**:成因、症狀、機制、常見困擾(**至少 8** 個)
|
||||
- **L2 周邊情境**:治療階段、生活事件、壓力、換季、相鄰品類、使用習慣等(**至少 12** 個)
|
||||
- 痛點/求助類(pain、symptom、cause)合計 **至少 10 個**
|
||||
|
||||
## 節點撰寫規則
|
||||
- `label`:2~14 字,具體、可拿去 Threads 找討論
|
||||
- `nodeKind`:`pain`、`symptom`、`cause`、`knowledge`
|
||||
- `relation`:**必填**,1~2 句(**25~55 字**),說明與種子詞的脈絡
|
||||
- `placementValue`:**必填**,1~2 句(**25~55 字**),說明為何與置入產品相關
|
||||
- `productFitScore`:0–100
|
||||
- `relevanceQueries`:**必填**,1~2 組 Threads 搜尋短句(相關軌)。每組 **6~16 字、2~4 個詞、空格分隔**,像真人會打進搜尋框;必須同時包含「困擾/情境」和「品類/用途」(例:「化療 沐浴乳 推薦」「無香 洗衣精 請問」),不要寫完整長問句
|
||||
- `recencyQueries`:痛點/求助類**必填** 1~2 組近期求助搜尋短句(近期軌)。格式同上,但加上「請問/有人/推薦/怎麼辦」等求助意圖(例:「敏感肌 請問 保養」「沐浴乳 推薦 有人」)
|
||||
- 無外部網址時 `evidenceUrls` 留空陣列即可
|
||||
|
||||
## 品質禁忌
|
||||
- 不要寫行銷話術或空泛評語
|
||||
- `relation` 與 `placementValue` 不可重複
|
||||
- 節點要貼近台灣 Threads 使用者會發文的語氣與情境
|
||||
- 搜尋短句不要寫品牌名,不要寫抽象分類詞(❌「癌症」「保養」「環境荷爾蒙」),也不要整句複製受眾提問;要像能在 Threads 找到貼文的查詢(✅「乳癌 洗衣精 推薦」「無香 洗衣精 請問」)
|
||||
|
||||
## 輸出格式
|
||||
只回傳 JSON,不要 markdown 說明(結構同 Brave 模式,含完整 relation / placementValue 句子)。
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{{brand_line}}{{product_line}}{{topic_line}}{{goals_line}}種子詞:{{seed}}
|
||||
{{product_brief_line}}{{target_audience_line}}{{persona_line}}{{research_pillars_line}}{{research_questions_line}}
|
||||
請先整合品牌、置入產品與主題目標的脈絡,再建立**完整**三層知識圖譜:
|
||||
- 節點 24~32 個,L1≥8、L2≥12(廣度優先,多方向觸及)
|
||||
- 每個節點都要寫滿 relation、placementValue(各 25~55 字),以及 relevanceQueries / recencyQueries(各 1~2 組 Threads 搜尋短句,6~16 字、2~4 詞、像真人會搜尋)
|
||||
- 節點要具體、可拿去 Threads 找討論;不可精簡或省略
|
||||
|
|
@ -13,8 +13,11 @@
|
|||
4. 不硬推:不要「推薦你試試」「一定要買」「限時優惠」。
|
||||
5. Threads 語感:短句、口語、台灣繁體,可適度用 emoji(0~1 個)。
|
||||
6. 誠實:不承諾療效、不捏造誇大數據。
|
||||
7. 人設與語氣是硬約束:如果有提供人設,留言要符合該角色的用字、節奏、距離感與價值觀。
|
||||
8. 產品脈絡是硬約束:如果有提供品牌與產品,必須判斷產品如何自然幫上忙;至少一則草稿要明確參考產品特點,但不能硬業配。
|
||||
9. 如果產品詳情出現「留言 CTA 連結」,至少一則草稿可以在後段自然附上完整網址;沒有連結時不要捏造網址。
|
||||
|
||||
每則留言 ≤ 500 字。產出 2 種不同切角(例如:純共情建議版 / 輕度分享經驗帶品牌版)。
|
||||
|
||||
只回傳 JSON,不要 markdown。格式:
|
||||
{"relevance":0.8,"reason":"一句話評估","drafts":[{"text":"留言正文","angle":"切角","rationale":"為何這樣寫"}]}
|
||||
{"relevance":0.8,"reason":"一句話評估","drafts":[{"text":"留言正文","angle":"切角","rationale":"為何這樣寫"}]}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ func toPlacementSettingsData(settings *placementusecase.Settings) types.MemberPl
|
|||
BraveAPIKeyConfigured: settings.BraveAPIKeyConfigured,
|
||||
BraveCountry: settings.BraveCountry,
|
||||
BraveSearchLang: settings.BraveSearchLang,
|
||||
ExpandStrategy: settings.ExpandStrategy,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue