Compare commits
556 Commits
14236f355a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20691b5057 | |||
| 3bf87a93fb | |||
| 4623c68d4e | |||
| f79dc87d75 | |||
| d4302acb6a | |||
| b7fd98c8c7 | |||
| 0b29283043 | |||
| 9dba1e74b0 | |||
| 4c9fe11fc9 | |||
| a356a5895f | |||
| 2e042e18c5 | |||
| 83e74ad1f4 | |||
| b70caddff1 | |||
| d6e34973a4 | |||
| 7007c90665 | |||
| ca7a502514 | |||
| dc471ecc60 | |||
| e91715bf2c | |||
| 1e4c1b42b7 | |||
| 0190a6c206 | |||
| 6ef4160da2 | |||
| 078c9f008a | |||
| 918151bda8 | |||
| 2ce6721c35 | |||
| c5303151c0 | |||
| ee61405ff1 | |||
| fef5f7a835 | |||
| e47ccdb762 | |||
| 4b6996b0f7 | |||
| 0f65aa53e4 | |||
| ea3485cde6 | |||
| d6366a38f3 | |||
| 0f8c71c552 | |||
| 1401c5703d | |||
| 92329f6fd5 | |||
| d0047c2b9d | |||
| 088944499c | |||
| a9fdbf8a93 | |||
| f46851d481 | |||
| 11b3700959 | |||
| 1db8a0063d | |||
| f017a61c79 | |||
| 1694823129 | |||
| a4614ebeae | |||
| 875e750f77 | |||
| 9cb40fb4e5 | |||
| 383f48c71e | |||
| 6be74737c2 | |||
| 3106716e70 | |||
| a126155948 | |||
| f509339cbb | |||
| e72a52a950 | |||
| eecaefc26d | |||
| b3c0683364 | |||
| 17321d948e | |||
| 8552cbc184 | |||
| b1c786e59d | |||
| b885d02ac4 | |||
| b35fab777e | |||
| 43081bea0e | |||
| bebe5797e7 | |||
| 9e1001b935 | |||
| e5465ad136 | |||
| 21d46d95dd | |||
| ac4a574ef2 | |||
| c985d2c605 | |||
| b4e873b5b0 | |||
| 6c5e93f64e | |||
| 6b7eb5a9c1 | |||
| 4b28ef3afa | |||
| 211aff1e45 | |||
| 37ca8e594e | |||
| c9a094969d | |||
| e8dbf8092a | |||
| 21cf0114f4 | |||
| 20f83cee33 | |||
| 1e77123394 | |||
| fbd8d26ec6 | |||
| 6f505b8cb1 | |||
| e1722e3963 | |||
| b1e28aa725 | |||
| 532b794c11 | |||
| e7f6edf7c5 | |||
| 42cf39d0da | |||
| 74196396c5 | |||
| 4393ba706b | |||
| 714224a9b4 | |||
| ea93dc522b | |||
| 408b6a3df7 | |||
| e6ff234031 | |||
| 912cd18e48 | |||
| a06cc424ca | |||
| e87c43a7a4 | |||
| 0c12c3527f | |||
| 5ed9d265f6 | |||
| 24229d00ae | |||
| 43f8b111ad | |||
| a9f38e1248 | |||
| 87651c9449 | |||
| a1a37ead9e | |||
| 978aa14f8b | |||
| 030365bed0 | |||
| 8c5bfa453f | |||
| 11d86450c3 | |||
| 90f6af6ab3 | |||
| 83113ab50c | |||
| 20514193e8 | |||
| 7a470aad44 | |||
| de8adaeadd | |||
| 5cde24115b | |||
| 318190c93f | |||
| c8684280af | |||
| 6895e2f8dc | |||
| 34619dc70b | |||
| 47cdc43aa5 | |||
| 2270072fe5 | |||
| 15f24dc890 | |||
| 2915f2b697 | |||
| 7640a2b4a8 | |||
| 427522bd1a | |||
| 0bddc5c607 | |||
| 54c677f75a | |||
| 01bb837525 | |||
| 8ceb0af736 | |||
| ecf1f643b2 | |||
| 077d411f83 | |||
| 6674755800 | |||
| d919c75ea7 | |||
| 3a71c91eeb | |||
| 9d0e9aa8aa | |||
| d9c39a0206 | |||
| 0f73b6b07d | |||
| faffca0967 | |||
| 49c5c57be5 | |||
| 6053e69afc | |||
| 1e5e1bcdff | |||
| 64fbbb7958 | |||
| cfbb72051f | |||
| bf5897fc85 | |||
| ad6c744f2c | |||
| aad9bfbe8b | |||
| 42bd53ee7b | |||
| 86694ae4fe | |||
| 41225b3337 | |||
| 6bb5c2fb40 | |||
| bd1773e29e | |||
| 685320f3cf | |||
| b3982c8f72 | |||
| 002c0893f8 | |||
| d6081ba2d3 | |||
| 10cb3ae1df | |||
| e3348da642 | |||
| 088bbaa097 | |||
| be322557ee | |||
| 70438caa1f | |||
| e16029ebdb | |||
| cefc3119c0 | |||
| 5485d4858a | |||
| fbd963db86 | |||
| 9095423026 | |||
| 6eb24090ed | |||
| 8cb5a01431 | |||
| 8a4a8790ca | |||
| 2200748122 | |||
| 7bc0a7cd77 | |||
| b84efd730b | |||
| 11bd223612 | |||
| c3a5d7210f | |||
| 07c4459085 | |||
| c057304981 | |||
| d1245d040c | |||
| 34ca407ca2 | |||
| b1ef778fc5 | |||
| 30706e2eb6 | |||
| 6062445c12 | |||
| 13da2226c3 | |||
| 1e377e1559 | |||
| eb75d692f5 | |||
| 6c25866487 | |||
| 6ac7469f26 | |||
| d1b2b6a4ba | |||
| 2abfa5cb23 | |||
| 227e294bd3 | |||
| ace0339d33 | |||
| 8812bd870a | |||
| b3fac4f442 | |||
| 19aed304cb | |||
| bbe5221e57 | |||
| ec0ccf649e | |||
| 84d90f6e1c | |||
| ddfe0ca3eb | |||
| 943f676414 | |||
| 06162b1e6e | |||
| c3659eb6c5 | |||
| 16941d76e8 | |||
| 9f91dae1a4 | |||
| 2a552d3cc8 | |||
| f37b21a408 | |||
| df7a8d985e | |||
| c5d0c84183 | |||
| 53a78a1062 | |||
| ca8bcb3fed | |||
| 4b4f91c052 | |||
| 6c3a84b8ec | |||
| 2ff2645240 | |||
| f2143b3889 | |||
| 810cc76d40 | |||
| 0a91f43c46 | |||
| 3d321f2b4b | |||
| 6ba29599aa | |||
| 658ed13571 | |||
| 15ee3c3301 | |||
| 2b5009f864 | |||
| d9b612253a | |||
| db4322006d | |||
| a05e6ba8ca | |||
| 4a333434ac | |||
| 119ac88e1e | |||
| c4cb18a25c | |||
| 50e811c5dd | |||
| 5ec7c2461b | |||
| 5f0fed7f13 | |||
| 070f2de3f1 | |||
| 01ebd2e7d9 | |||
| 7db9869722 | |||
| 97cb38ca7f | |||
| 90c408aa77 | |||
| 55f2fa9cff | |||
| 3ded781059 | |||
| 4eaeea9833 | |||
| 9709e5b019 | |||
| 94d6a39ce8 | |||
| 804fdcba26 | |||
| 204cee67d6 | |||
| 779e78405e | |||
| 16a651f670 | |||
| e508b7dc35 | |||
| 6c5481971b | |||
| d7e235c008 | |||
| 8707d322e4 | |||
| b4dd21e67a | |||
| 448dbd5f48 | |||
| a826e00399 | |||
| 134e628e5e | |||
| ce3a734e81 | |||
| fb81c51dc8 | |||
| 715e1598ce | |||
| 57a4a72ff1 | |||
| e14278ec69 | |||
| ff3134b838 | |||
| 95c5dc4217 | |||
| 9fb1c37eae | |||
| 3bd819b5e2 | |||
| b936233e7c | |||
| 4f85496fe5 | |||
| 2a2209a86c | |||
| 30bc627ae7 | |||
| d972ea66c3 | |||
| 66165ebb88 | |||
| 5621cc7687 | |||
| fb54998def | |||
| b792cdb8d5 | |||
| 1d4bff31c4 | |||
| e31bf549a8 | |||
| aec0fdcd31 | |||
| f1f1dc98a6 | |||
| 8b5cb2c16a | |||
| 77b8d05ad7 | |||
| f0cb06268e | |||
| f074cbec2d | |||
| 84548a326e | |||
| 5f5010ded4 | |||
| 755dea63f4 | |||
| 20c5268def | |||
| dc3f9cb6a9 | |||
| 262366bc1e | |||
| 5fc914cd8f | |||
| 8f859274c4 | |||
| a347da075c | |||
| e754fb30f5 | |||
| f0c0c18beb | |||
| d11023decb | |||
| 70a256bbe4 | |||
| ebbfa6299a | |||
| d4fb485931 | |||
| b6dffb4d42 | |||
| 240bd38541 | |||
| bb0b0dff25 | |||
| 47e5315487 | |||
| 97b15cb985 | |||
| 6d416aab78 | |||
| 2c13e7cc85 | |||
| 4f67cd02fa | |||
| 868906b8c6 | |||
| bd97cc1e97 | |||
| 7552ce4263 | |||
| 17034ea6ea | |||
| fe60c8d330 | |||
| 4755e34c14 | |||
| ad1c721ba8 | |||
| 1c705b0ef3 | |||
| 68dec2e53d | |||
| e33a2310af | |||
| fceca88db4 | |||
| d66a321982 | |||
| e03d074222 | |||
| 2eeb98a723 | |||
| 657ffdc55f | |||
| f54da7d46a | |||
| dc92c3d42d | |||
| 24a57f2b69 | |||
| b9d3242341 | |||
| 5e9a51c9e8 | |||
| 5844567048 | |||
| 0906c3ba35 | |||
| ff4ef299ad | |||
| 5ebcbae8b5 | |||
| 1cd3cf8830 | |||
| c18fd8e52b | |||
| dc482b32e4 | |||
| ef026e7ac6 | |||
| 80a54d056e | |||
| 83192eb66c | |||
| 9a0bbeccd5 | |||
| 7a9690526a | |||
| 7a7e3d1ce0 | |||
| eb547a0367 | |||
| 096e291ed8 | |||
| 7c8d079f74 | |||
| 85e5f96379 | |||
| 47a4b1e231 | |||
| be0094b83f | |||
| e948393906 | |||
| 0beceefeef | |||
| 355667cf9c | |||
| 26b9eea0dc | |||
| 3b9dcfe0dd | |||
| 1d4354e402 | |||
| 8604c6292d | |||
| 21666f4372 | |||
| f83b900320 | |||
| a7b2fc0d9d | |||
| 327d0b4e81 | |||
| 8e7a3806c5 | |||
| abf475433b | |||
| 7336fd090e | |||
| 62d79b2669 | |||
| 56fbe3fc4b | |||
| a5495aeaa4 | |||
| 88b5ea9ce2 | |||
| 54d67f892c | |||
| 8411e2c73e | |||
| 86a6b75124 | |||
| 08a32e4357 | |||
| f6de95afb6 | |||
| caacb072a2 | |||
| f80683ce82 | |||
| 71f52e4d59 | |||
| 756f280bbc | |||
| a508a5633a | |||
| 1d6c1b4329 | |||
| 7b3ddd1b19 | |||
| 32e021cfc7 | |||
| 3749d79168 | |||
| 0de2d3cf93 | |||
| 55c37df703 | |||
| c2939459e7 | |||
| 7aa7ccc6d5 | |||
| d46d2cb30b | |||
| 20b51f706c | |||
| eb04b954a5 | |||
| a75ff069df | |||
| d39d9f26ac | |||
| 9dd517e82a | |||
| 496e3a6a73 | |||
| d6547edf0d | |||
| 5749d4d35d | |||
| 2477342272 | |||
| 62a9009fea | |||
| 0fadc774d8 | |||
| eef2e3967e | |||
| 2a8635e9ed | |||
| 6c46759848 | |||
| e3d5eaf6f3 | |||
| 6004bcf66d | |||
| a5a9337838 | |||
| 4d6296bce3 | |||
| c6366ad238 | |||
| b671d275eb | |||
| bb97aa3ec8 | |||
| 335ea012cc | |||
| c168656fe1 | |||
| 955fc4ee1e | |||
| 1c255152d7 | |||
| 728428ce95 | |||
| 00a610c374 | |||
| 496646fb32 | |||
| cb6e2d992a | |||
| 7011d3ef3a | |||
| eb322b7450 | |||
| 4fde9e6f58 | |||
| 7d78fae77f | |||
| e82ff83a5f | |||
| fac2e65ed8 | |||
| 42242f86eb | |||
| c5682e07a7 | |||
| 8f0b1fbbfa | |||
| e88989d3c1 | |||
| f38631cdae | |||
| b2accba65a | |||
| 8d92e50009 | |||
| bd7875b36a | |||
| 5ac5cce0fe | |||
| ae4f0d4270 | |||
| 447c6babc3 | |||
| 6f62b34b12 | |||
| af3df87672 | |||
| c6de615271 | |||
| 7c4d7b4534 | |||
| cc17c29266 | |||
| 889dc417a9 | |||
| e16cf8f817 | |||
| d4a4849943 | |||
| 21721d34a0 | |||
| 86be8c2a53 | |||
| 753ecdbbf2 | |||
| 1ec45acb95 | |||
| d1fec71bdc | |||
| 4a8b0092d7 | |||
| e1ae0f7501 | |||
| adb5cdb54e | |||
| e691ed9a7d | |||
| c019ab1681 | |||
| c15ea96e2f | |||
| de015a2440 | |||
| 7acc1979c8 | |||
| 3152bc23f4 | |||
| b23346143f | |||
| b867b8ce13 | |||
| f3c7ce72de | |||
| 57b7a4921d | |||
| 916b04af6a | |||
| 43ee920617 | |||
| d11aadce8a | |||
| 5dd7b6d601 | |||
| 1d535519ef | |||
| de80ebd707 | |||
| 6e18782d3b | |||
| 86e7f727eb | |||
| de91f424a3 | |||
| cce84de8be | |||
| 678440a2bd | |||
| a3f9f1cb39 | |||
| 9a02ed1fd3 | |||
| 6f8b199548 | |||
| c3b8794621 | |||
| e33219af0b | |||
| eb9bd65033 | |||
| a6fd44c697 | |||
| ad939dde40 | |||
| 26997a7dc7 | |||
| 94969f97a8 | |||
| 3e46cc41ca | |||
| 214eb320fa | |||
| c8ee3bb95b | |||
| 6ffa04f847 | |||
| 262c088c8a | |||
| 074dd4041f | |||
| 243c101981 | |||
| 011eac7682 | |||
| 535ffea45a | |||
| 9d5583935d | |||
| a2bd26682e | |||
| a588a26144 | |||
| 14674c4e9a | |||
| 74891eaa60 | |||
| 4cc802ed95 | |||
| b82a10e580 | |||
| 4646b79e6e | |||
| 786033f202 | |||
| 25f4f1f98b | |||
| 336bc90b4e | |||
| 2980807587 | |||
| 7c7093d67c | |||
| 2603c7ce20 | |||
| 4f68b568a7 | |||
| fdb2fedd40 | |||
| b0f12ba6c6 | |||
| aee3937625 | |||
| d9bfd04c76 | |||
| cd292b2632 | |||
| 80ccb20f99 | |||
| ce4f7b3ef6 | |||
| 1b368e9896 | |||
| a542b1af7d | |||
| 3ce93149d5 | |||
| 5530402604 | |||
| cb750f888b | |||
| 598adcbeb5 | |||
| d67e1fcd67 | |||
| 7eda717326 | |||
| 28e3af12ec | |||
| c9f10aca4a | |||
| 706ca410ca | |||
| 4c6e96d59c | |||
| 7cf4784c08 | |||
| afc159c84d | |||
| bdfcdee5fd | |||
| 3b118725ca | |||
| 6344f957fa | |||
| 0be5693aee | |||
| 5a493664f2 | |||
| c6328f7b04 | |||
| d6d6faf5c7 | |||
| 437838c28b | |||
| 4cb6296a3d | |||
| 9e7efc3f12 | |||
| 6b95c1e5a0 | |||
| 7d20527a17 | |||
| e91a5e6be6 | |||
| c4406b9ecd | |||
| 65ffdec7d2 | |||
| 8b916194aa | |||
| caeb72d310 | |||
| ba33e00ce3 | |||
| bb76e62774 | |||
| 649b99d143 | |||
| 4b339d9d4f | |||
| d2606d7317 | |||
| 33a011a086 | |||
| e04c000a3e | |||
| 1a251cae24 | |||
| 2d98c4176b | |||
| f7c583b806 | |||
| a618544823 | |||
| 2a1d8716c7 | |||
| f5c58a5aa5 | |||
| 9ac142e1de | |||
| 819c35adfc | |||
| 6a1a2c4552 | |||
| ff975defbd | |||
| bc9ba3901e | |||
| c9737b380f | |||
| 09e5ab4e30 | |||
| 4f854c5540 | |||
| 0aa12d94c5 | |||
| 2265da49c6 | |||
| c11aa2a9cb | |||
| 021f682be5 | |||
| 5e06adea3d | |||
| e6df50bbb1 | |||
| 57ad1fd67d | |||
| 4589592b67 | |||
| c7e12ea9fe | |||
| 438aba1dd1 | |||
| 7ab0733400 |
41
.claude/settings.json
Normal file
41
.claude/settings.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git show:*)",
|
||||||
|
"Bash(git branch:*)",
|
||||||
|
"Bash(git stash list:*)",
|
||||||
|
"Bash(git remote -v)",
|
||||||
|
"Bash(docker ps:*)",
|
||||||
|
"Bash(docker logs:*)",
|
||||||
|
"Bash(docker compose ps:*)",
|
||||||
|
"Bash(docker compose logs:*)",
|
||||||
|
"Bash(docker compose config:*)",
|
||||||
|
"Bash(docker images:*)",
|
||||||
|
"Bash(pytest:*)",
|
||||||
|
"Bash(python -m pytest:*)",
|
||||||
|
"Bash(python -V)",
|
||||||
|
"Bash(python -c:*)",
|
||||||
|
"Bash(pip list:*)",
|
||||||
|
"Bash(pip show:*)",
|
||||||
|
"Bash(pip freeze:*)",
|
||||||
|
"Bash(uvicorn --version)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(cat docker-compose.yml)"
|
||||||
|
],
|
||||||
|
"deny": [
|
||||||
|
"Read(.env)",
|
||||||
|
"Read(.env.*)",
|
||||||
|
"Read(**/.env)",
|
||||||
|
"Read(**/.env.*)",
|
||||||
|
"Read(**/credentials*)",
|
||||||
|
"Read(**/secrets*)",
|
||||||
|
"Read(**/*.pem)",
|
||||||
|
"Read(**/*.key)",
|
||||||
|
"Read(**/lotto.db)",
|
||||||
|
"Read(**/stock.db)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
79
.env.example
79
.env.example
@@ -49,4 +49,81 @@ PGID=1000
|
|||||||
# 실제 KIS API 호출 및 AI 분석은 Windows PC에서 수행됩니다.
|
# 실제 KIS API 호출 및 AI 분석은 Windows PC에서 수행됩니다.
|
||||||
|
|
||||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||||
WINDOWS_AI_SERVER_URL=http://192.168.0.5:8000
|
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||||
|
|
||||||
|
# Admin API Key — /api/trade/* 등 민감 엔드포인트 보호.
|
||||||
|
# 운영 .env에는 반드시 값을 채워야 함. 빈 값이면 503 응답으로 거부됨 (CODE_REVIEW F2).
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
|
||||||
|
# 개발 모드: 위 ADMIN_API_KEY 비워둔 채로 trade/admin 엔드포인트 호출 허용.
|
||||||
|
# 운영 환경에서는 절대 true로 두지 말 것. 기본 false (보호 활성).
|
||||||
|
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||||
|
|
||||||
|
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
|
|
||||||
|
# 뉴스 요약 provider 전환: claude (기본) | ollama
|
||||||
|
LLM_PROVIDER=claude
|
||||||
|
|
||||||
|
# Ollama 서버 (LLM_PROVIDER=ollama 일 때만 사용)
|
||||||
|
OLLAMA_URL=http://192.168.45.59:11435
|
||||||
|
OLLAMA_MODEL=qwen3:14b
|
||||||
|
|
||||||
|
# [BLOG LAB]
|
||||||
|
# Naver Search API (https://developers.naver.com 에서 발급)
|
||||||
|
NAVER_CLIENT_ID=
|
||||||
|
NAVER_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# 블로그 데이터 저장 경로
|
||||||
|
# BLOG_DATA_PATH=./data/blog
|
||||||
|
|
||||||
|
# [MUSIC LAB]
|
||||||
|
# Suno API Key (https://suno.com 에서 발급, 미설정 시 Suno provider 비활성화)
|
||||||
|
SUNO_API_KEY=
|
||||||
|
|
||||||
|
# 로컬 MusicGen AI Server URL (미설정 시 Local provider 비활성화)
|
||||||
|
# MUSIC_AI_SERVER_URL=http://192.168.45.59:8765
|
||||||
|
|
||||||
|
# CORS 허용 도메인 (콤마 구분)
|
||||||
|
CORS_ALLOW_ORIGINS=https://gahusb.synology.me,http://localhost:3007,http://localhost:8080
|
||||||
|
|
||||||
|
# [REALESTATE LAB — agent-office push notify]
|
||||||
|
AGENT_OFFICE_URL=http://agent-office:8000
|
||||||
|
REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||||
|
REALESTATE_DASHBOARD_URL=http://localhost:8080/realestate
|
||||||
|
REALESTATE_NOTIFY_TIMEOUT=15
|
||||||
|
|
||||||
|
# [MUSIC LAB — YouTube Video Generation]
|
||||||
|
PEXELS_API_KEY=
|
||||||
|
YOUTUBE_DATA_API_KEY=
|
||||||
|
# VIDEO_DATA_DIR=/app/data/videos # 기본값, 재정의 필요 시만 설정
|
||||||
|
|
||||||
|
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
|
||||||
|
# Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||||
|
DSM_HOST=https://gahusb.synology.me:5001
|
||||||
|
DSM_USER=
|
||||||
|
DSM_PASS=
|
||||||
|
# LAN IP로 DSM 접근 시 self-signed cert가 IP에 매칭 안 되어 검증 실패. 그 경우 false 설정 (LAN 내부 통신이라 허용 가능). 도메인 + 정상 cert면 true 유지.
|
||||||
|
DSM_VERIFY_SSL=true
|
||||||
|
|
||||||
|
# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
|
||||||
|
BACKEND_HMAC_SECRET=
|
||||||
|
|
||||||
|
# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
|
||||||
|
SUPABASE_URL=https://<project>.supabase.co
|
||||||
|
SUPABASE_SERVICE_KEY=
|
||||||
|
|
||||||
|
# admin upload 토큰 TTL (초). default 1800 = 30분
|
||||||
|
UPLOAD_TOKEN_TTL_SEC=1800
|
||||||
|
|
||||||
|
# 호스트 마운트 경로 (로컬 ./data/packs, NAS /volume1/docker/webpage/media/packs)
|
||||||
|
PACK_DATA_PATH=./data/packs
|
||||||
|
|
||||||
|
# 컨테이너 내부 PACK_BASE_DIR (routes.py가 파일 저장 시 사용. docker-compose volume의 컨테이너 측 경로와 반드시 일치)
|
||||||
|
PACK_BASE_DIR=/app/data/packs
|
||||||
|
|
||||||
|
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
|
||||||
|
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정.
|
||||||
|
# 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
|
||||||
|
PACK_HOST_DIR=/docker/webpage/media/packs
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -63,3 +63,14 @@ uploads/
|
|||||||
################################
|
################################
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
################################
|
||||||
|
# Local working files
|
||||||
|
################################
|
||||||
|
# Superpowers 스킬 캐시·세션 메타
|
||||||
|
.superpowers/
|
||||||
|
# 임시 코드 리뷰 노트 (작업 끝나면 폐기 또는 docs/로 이동)
|
||||||
|
CODE_REVIEW.md
|
||||||
|
|||||||
209
CHECK_POINT.md
Normal file
209
CHECK_POINT.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# web-backend CHECK_POINT
|
||||||
|
|
||||||
|
> NAS Docker 11 컨테이너(9 백엔드 + frontend + deployer). Synology Celeron J4025 (2C 2.0GHz) 18GB.
|
||||||
|
> 2026-05-18 작성 — uvicorn CPU 폭주 진단 결과 정리.
|
||||||
|
|
||||||
|
## 🔴 즉시 (오늘, 총 1시간 5분)
|
||||||
|
|
||||||
|
### 1. 09:00 cron 5분 스태거링 ⭐ 가장 큰 효과
|
||||||
|
|
||||||
|
**파일**: `agent-office/app/scheduler.py:72-76`
|
||||||
|
```python
|
||||||
|
# 변경 전 — 09:00 동시 실행 (CPU 폭주 원인 #1)
|
||||||
|
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0)
|
||||||
|
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0)
|
||||||
|
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0)
|
||||||
|
|
||||||
|
# 변경 후 — 5분 스태거링
|
||||||
|
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends")
|
||||||
|
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=5, id="lotto_curate")
|
||||||
|
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, id="youtube_research")
|
||||||
|
```
|
||||||
|
|
||||||
|
**파일**: `realestate-lab/app/main.py:51`
|
||||||
|
```python
|
||||||
|
# 변경 전
|
||||||
|
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=0, id="collect")
|
||||||
|
|
||||||
|
# 변경 후
|
||||||
|
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, id="collect")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] agent-office scheduler.py 수정 (2026-05-18)
|
||||||
|
- [x] realestate-lab main.py 수정 (2026-05-18)
|
||||||
|
- [ ] git commit + push (Gitea Webhook 자동 빌드)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. insta-lab Playwright Semaphore(1) ⭐
|
||||||
|
|
||||||
|
**파일**: `insta-lab/app/main.py` (모듈 레벨 추가)
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# 모듈 레벨에 한 번만 선언
|
||||||
|
RENDER_SEMAPHORE = asyncio.Semaphore(1) # Chromium 동시 실행 1개로 제한
|
||||||
|
|
||||||
|
# 카드 렌더 백그라운드 함수에 감싸기
|
||||||
|
async def _bg_render(task_id: str, slate_id: int):
|
||||||
|
async with RENDER_SEMAPHORE:
|
||||||
|
await card_renderer.render_slate(slate_id, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] card_renderer.render_slate를 Semaphore(1)로 감쌈 (2026-05-18, lazy init)
|
||||||
|
- [ ] 동시 2개 요청 테스트 (curl 동시 2회 → 순차 처리되는지 확인)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. healthcheck interval 60s
|
||||||
|
|
||||||
|
**파일**: `docker-compose.yml` (모든 9 컨테이너)
|
||||||
|
```yaml
|
||||||
|
# 변경 전
|
||||||
|
healthcheck:
|
||||||
|
interval: 30s
|
||||||
|
|
||||||
|
# 변경 후
|
||||||
|
healthcheck:
|
||||||
|
interval: 60s
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] docker-compose.yml 10개 healthcheck 일괄 변경 (9 백엔드 + frontend, 2026-05-18)
|
||||||
|
- [ ] `docker compose up -d` 재기동
|
||||||
|
- [ ] `docker stats` 로 CPU 5% 정도 감소 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. uvicorn --workers 1 명시
|
||||||
|
|
||||||
|
**모든 Dockerfile CMD**:
|
||||||
|
```dockerfile
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
|
```
|
||||||
|
|
||||||
|
영향 9 파일 (모두 2026-05-18 적용):
|
||||||
|
- [x] lotto/Dockerfile
|
||||||
|
- [x] stock/Dockerfile
|
||||||
|
- [x] music-lab/Dockerfile
|
||||||
|
- [x] insta-lab/Dockerfile
|
||||||
|
- [x] realestate-lab/Dockerfile
|
||||||
|
- [x] agent-office/Dockerfile
|
||||||
|
- [x] personal/Dockerfile
|
||||||
|
- [x] packs-lab/Dockerfile
|
||||||
|
- [x] travel-proxy/Dockerfile
|
||||||
|
|
||||||
|
→ `docker compose build --no-cache` 후 재기동.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. lotto Monte Carlo 08:05 → 08:30
|
||||||
|
|
||||||
|
**파일**: `lotto/app/main.py:86`
|
||||||
|
```python
|
||||||
|
# 변경 전 — stock 08:00과 5분 차이로 겹침
|
||||||
|
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
|
||||||
|
|
||||||
|
# 변경 후 — 25분 분리
|
||||||
|
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=30)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] lotto/app/main.py 수정 (2026-05-18)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 중기 (1~2주)
|
||||||
|
|
||||||
|
### 6. Chromium Browser Pool 재설계 (insta-lab) ✅ 2026-05-18
|
||||||
|
- 매번 launch X → 1개 인스턴스 재사용
|
||||||
|
- 카드 10장 렌더 시간 30% 단축 기대
|
||||||
|
- [x] `card_renderer.py` 내부에 모듈 레벨 `_PLAYWRIGHT`/`_BROWSER` + `init_browser`/`shutdown_browser` 함수 (별도 모듈 분리 안 함, 같은 파일에 인접 배치)
|
||||||
|
- [x] `_render_slate_locked` 본체에서 `_get_browser()` 재사용 (crashed 시 lazy 재초기화)
|
||||||
|
- [x] `main.py` startup hook에서 `init_browser()`, shutdown hook에서 `shutdown_browser()`
|
||||||
|
|
||||||
|
### 7. stock 뉴스 스크랩 비동기화 — ⚠️ 보류 2026-05-18
|
||||||
|
- **재진단**: stock은 `BackgroundScheduler` 사용 중 → main loop 블로킹 없음 (이미 별도 thread)
|
||||||
|
- `fetch_market_news`의 4개 동기 `requests.get`은 network I/O wait라 CPU 거의 사용 안 함
|
||||||
|
- `to_thread`로 wrap해도 BackgroundScheduler 환경에서 사실상 의미 없음
|
||||||
|
- 진짜 효과를 보려면 AsyncIOScheduler 전환 + scraper.py 4개 fetch를 `aiohttp` 병렬로 — **큰 리팩토링 vs 효과 불명확**
|
||||||
|
- [ ] 박재오 판단: 큰 리팩토링 진행 여부
|
||||||
|
|
||||||
|
### 8. realestate 수집 병렬화 ✅ 2026-05-18
|
||||||
|
- **파일**: `realestate-lab/app/main.py:scheduled_collect`
|
||||||
|
- `collect_all()` + `delete_old_completed_announcements()` 병렬
|
||||||
|
- BackgroundScheduler 환경이라 `asyncio.gather` 대신 `ThreadPoolExecutor(max_workers=2)` 사용 (효과 동일)
|
||||||
|
- 매칭은 순차 유지 (DB 일관성)
|
||||||
|
- [x] ThreadPoolExecutor 적용
|
||||||
|
|
||||||
|
### 9. lotto Monte Carlo 시뮬레이션 빈도 검토
|
||||||
|
- 현재 6회/일 (00·04·08·12·16·20)
|
||||||
|
- 실제 필요 빈도 박재오 결정 — 3회/일(아침·점심·저녁)로 줄이면 CPU 50% 감소
|
||||||
|
- [ ] 박재오 의사결정 후 cron 변경
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 장기 (1개월+)
|
||||||
|
|
||||||
|
### 10. 무거운 작업 Windows AI 서버로 이전 ✅ 이미 적용 상태 (2026-05-18 확인)
|
||||||
|
- **확인 결과**: NAS `.env`가 이미 `LLM_PROVIDER=claude` + `OLLAMA_URL=http://192.168.45.59:11435`로 설정됨
|
||||||
|
- 실 운영은 Anthropic Claude (원격 API) — NAS Celeron에서 LLM 추론 안 함
|
||||||
|
- Ollama fallback 사용 시에도 Windows AI 서버로 통일
|
||||||
|
- stock 외 다른 컨테이너에 ollama/qwen 호출 코드 없음
|
||||||
|
- 결론: 코드/설정 변경 불필요
|
||||||
|
|
||||||
|
### 11. 컨테이너 리소스 제한 — ❌ 진행 금지 (박재오 명시 2026-05-18)
|
||||||
|
- J4025 2C 환경에서 cpus 0.5 제한은 오히려 throughput 손해
|
||||||
|
- 향후 작업자 무심코 도입하지 말 것
|
||||||
|
|
||||||
|
### 12. NAS 업그레이드 검토 — ⏸️ 보류 (박재오 명시 2026-05-18)
|
||||||
|
- 현재: Celeron J4025 (2C 2.0GHz)
|
||||||
|
- 대안: Ryzen N5105 (4C 2.0GHz) NAS — 4코어로 병렬성 2배
|
||||||
|
- 자금·우선순위 결정 대기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 최근 완료 (참고)
|
||||||
|
|
||||||
|
- 2026-05-15: insta-lab 신설 (포트 18700, Jinja2 + Playwright + Claude Sonnet)
|
||||||
|
- 2026-05-16: insta-lab Playwright 1080×1350 PNG 렌더 완성
|
||||||
|
- 2026-05-17: agent-office random idle 제거, ADMIN_API_KEY 강화 (stock)
|
||||||
|
- 2026-05-17: insta-lab minimal theme + design_importer 추가
|
||||||
|
- 2026-05-17: blog-lab 트랙 완전 폐기 (docker-compose에 없음, 위키 정정 완료)
|
||||||
|
- 2026-05-18: 🔴 즉시 5건 일괄 적용 — 09:00 cron 스태거링(insta/lotto/youtube/realestate), lotto Monte Carlo 08:30, insta-lab Semaphore(1), healthcheck 60s, uvicorn --workers 1 명시 (사용자 push + NAS deployer 재기동 대기)
|
||||||
|
- 2026-05-18: 🟡 중기 2건 적용 — #6 insta-lab Chromium Browser Pool (lifecycle hook), #8 realestate ThreadPoolExecutor 병렬 (collect/delete). #7 stock async는 BackgroundScheduler 사용 중이라 재진단 후 보류 (효과 미미). #9 Monte Carlo 빈도는 박재오 결정 대기.
|
||||||
|
- 2026-05-18: 🟢 장기 진단·결정 — #10은 이미 적용 상태 확인 (LLM_PROVIDER=claude, OLLAMA_URL=Windows AI). #11 컨테이너 리소스 제한 박재오 진행 금지. #12 NAS 업그레이드 보류. web-ai V1(:8000)+V2(:8001) 4개 process 종료 — NAS API polling 부담 즉시 감소.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 진단 커맨드 (NAS bash)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 실시간 CPU 사용 (상위 15)
|
||||||
|
top -b -n 1 | head -25
|
||||||
|
|
||||||
|
# 프로세스별 CPU 정렬
|
||||||
|
ps aux --sort=-%cpu | head -15
|
||||||
|
|
||||||
|
# uvicorn·chromium·python 프로세스만
|
||||||
|
ps aux | grep -E "uvicorn|chromium|python" | grep -v grep
|
||||||
|
|
||||||
|
# 스케줄러 실행 로그 (최근 50)
|
||||||
|
docker logs agent-office 2>&1 | grep -E "APScheduler|executing" | tail -50
|
||||||
|
|
||||||
|
# insta-lab Chromium 프로세스 개수
|
||||||
|
docker exec insta-lab ps aux | grep chromium | wc -l
|
||||||
|
|
||||||
|
# 컨테이너별 CPU/메모리 실시간
|
||||||
|
docker stats --no-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 참고
|
||||||
|
|
||||||
|
- 진단 풀 보고서: `C:\Users\jaeoh\Documents\Obsidian Vault\raw\2026-05-18-NAS-uvicorn-CPU-진단-개선안.md`
|
||||||
|
- 위키 페이지: [[사업-개인-웹-플랫폼]] (CPU 부하 진단 섹션 + 컨테이너 표)
|
||||||
|
- docker-compose.yml: 본 디렉토리 루트
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
- 2026-05-18: 페이지 신설. 즉시 5건 + 중기 4건 + 장기 3건. 진단 커맨드.
|
||||||
547
CLAUDE.md
547
CLAUDE.md
@@ -7,9 +7,9 @@
|
|||||||
## 1. 프로젝트 개요
|
## 1. 프로젝트 개요
|
||||||
|
|
||||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||||
- **서비스**: lotto-lab, stock-lab, travel-album, music-lab, deployer
|
- **서비스**: lotto-lab, stock, travel-proxy, music-lab, insta-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||||
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||||
- **인프라**: Docker Compose + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
| 메모리 | 18 GB |
|
| 메모리 | 18 GB |
|
||||||
| Docker | Synology Container Manager |
|
| Docker | Synology Container Manager |
|
||||||
| Git 서버 | Gitea (self-hosted, NAS 내부) |
|
| Git 서버 | Gitea (self-hosted, NAS 내부) |
|
||||||
| AI 서버 | Windows PC (192.168.45.59:8000) — NVIDIA 3070 Ti + Ollama |
|
| AI 서버 | Windows PC (192.168.45.59:8000) — NVIDIA RTX 5070 Ti (16GB VRAM) + Ollama |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,8 +31,8 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
```
|
```
|
||||||
/volume1
|
/volume1
|
||||||
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
|
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
|
||||||
│ ├── backend/ # lotto-backend 소스 (rsync 동기화)
|
│ ├── lotto/ # lotto 소스 (rsync 동기화)
|
||||||
│ ├── stock-lab/ # stock-lab 소스 (rsync 동기화)
|
│ ├── stock/ # stock 소스 (rsync 동기화)
|
||||||
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
|
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
|
||||||
│ ├── deployer/ # deployer 소스 (rsync 동기화)
|
│ ├── deployer/ # deployer 소스 (rsync 동기화)
|
||||||
│ ├── nginx/default.conf # Nginx 설정
|
│ ├── nginx/default.conf # Nginx 설정
|
||||||
@@ -53,11 +53,16 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
|
|
||||||
| 컨테이너 | 포트 | 역할 |
|
| 컨테이너 | 포트 | 역할 |
|
||||||
|---------|------|------|
|
|---------|------|------|
|
||||||
| `lotto-backend` | 18000 | 로또 데이터 수집·분석·추천 API |
|
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||||
| `stock-lab` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
| `stock` | 18500 | 주식 뉴스·AI 분석·KIS API 연동 |
|
||||||
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
|
| `music-lab` | 18600 | AI 음악 생성·라이브러리 관리 API |
|
||||||
|
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드) |
|
||||||
|
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
|
||||||
|
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||||
|
| `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
|
||||||
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
||||||
| `lotto-frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
||||||
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -66,14 +71,22 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
|
|
||||||
| 경로 | 프록시 대상 | 비고 |
|
| 경로 | 프록시 대상 | 비고 |
|
||||||
|------|------------|------|
|
|------|------------|------|
|
||||||
| `/api/` | `lotto-backend:8000` | lotto API (기본) |
|
| `/api/` | `lotto:8000` | lotto API (기본) |
|
||||||
| `/api/travel/` | `travel-proxy:8000` | travel API |
|
| `/api/travel/` | `travel-proxy:8000` | travel API |
|
||||||
| `/api/stock/` | `stock-lab:8000` | stock API |
|
| `/api/stock/` | `stock:8000` | stock API |
|
||||||
| `/api/trade/` | `stock-lab:8000` | KIS 실계좌 API |
|
| `/api/trade/` | `stock:8000` | KIS 실계좌 API |
|
||||||
| `/api/portfolio` | `stock-lab:8000` | trailing slash 유무 모두 매칭 |
|
| `/api/portfolio` | `stock:8000` | trailing slash 유무 모두 매칭 |
|
||||||
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
|
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
|
||||||
|
| `/api/insta/` | `insta-lab:8000` | 인스타 카드 자동 생성 API |
|
||||||
|
| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
|
||||||
|
| `/api/todos` | `personal:8000` | 투두 API |
|
||||||
|
| `/api/blog/` | `personal:8000` | 블로그 API |
|
||||||
|
| `/api/profile/` | `personal:8000` | 포트폴리오 API |
|
||||||
|
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket |
|
||||||
|
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
|
||||||
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
|
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
|
||||||
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
|
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
|
||||||
|
| `/media/videos/` | `/data/videos/` (파일 직접 서빙) | YouTube 영상 MP4 |
|
||||||
| `/media/travel/.thumb/` | `/data/thumbs/` (파일 직접 서빙) | 썸네일 캐시 |
|
| `/media/travel/.thumb/` | `/data/thumbs/` (파일 직접 서빙) | 썸네일 캐시 |
|
||||||
| `/media/travel/` | `/data/travel/` (파일 직접 서빙) | 원본 사진 |
|
| `/media/travel/` | `/data/travel/` (파일 직접 서빙) | 원본 사진 |
|
||||||
| `/assets/` | 정적 파일 (장기 캐시) | Vite 해시 파일 |
|
| `/assets/` | 정적 파일 (장기 캐시) | Vite 해시 파일 |
|
||||||
@@ -122,15 +135,18 @@ docker compose up -d
|
|||||||
| Lotto Backend | http://localhost:18000 |
|
| Lotto Backend | http://localhost:18000 |
|
||||||
| Travel API | http://localhost:19000 |
|
| Travel API | http://localhost:19000 |
|
||||||
| Stock Lab | http://localhost:18500 |
|
| Stock Lab | http://localhost:18500 |
|
||||||
|
| Insta Lab | http://localhost:18700 |
|
||||||
|
| Realestate Lab | http://localhost:18800 |
|
||||||
|
| Packs Lab | http://localhost:18950 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 서비스별 핵심 정보
|
## 9. 서비스별 핵심 정보
|
||||||
|
|
||||||
### lotto-lab (backend/)
|
### lotto-lab (lotto/)
|
||||||
- DB: `/app/data/lotto.db`
|
- DB: `/app/data/lotto.db`
|
||||||
- 데이터 소스: `smok95.github.io/lotto/results/`
|
- 데이터 소스: `smok95.github.io/lotto/results/`
|
||||||
- 파일 구조: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `utils.py`
|
- 파일 구조: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `utils.py`, `purchase_manager.py`, `strategy_evolver.py`
|
||||||
|
|
||||||
**lotto.db 테이블**
|
**lotto.db 테이블**
|
||||||
|
|
||||||
@@ -141,12 +157,23 @@ docker compose up -d
|
|||||||
| `simulation_runs` | 시뮬레이션 실행 기록 |
|
| `simulation_runs` | 시뮬레이션 실행 기록 |
|
||||||
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
|
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
|
||||||
| `best_picks` | 현재 활성 최적 번호 20개 (`is_active` 플래그로 교체) |
|
| `best_picks` | 현재 활성 최적 번호 20개 (`is_active` 플래그로 교체) |
|
||||||
| `todos` | 투두리스트 (UUID PK) |
|
| `purchase_history` | 구매 이력 (실제/가상, 번호, 전략 출처, 결과) |
|
||||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) |
|
| `strategy_performance` | 전략별 회차 성과 (EMA 입력 데이터) |
|
||||||
|
| `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) |
|
||||||
|
| `weekly_reports` | 주간 공략 리포트 캐시 |
|
||||||
|
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
|
||||||
|
| `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
||||||
|
| `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
||||||
|
| `weight_trials` | 주별 6일치 후보 가중치 (4 perturb + 2 dirichlet) |
|
||||||
|
| `auto_picks` | 매일 N=5 시도 번호 + 채점 결과 |
|
||||||
|
| `weight_base_history` | base 갱신 이력 (winner_4plus / ema_blend / unchanged / cold_start) |
|
||||||
|
|
||||||
**스케줄러 job**
|
**스케줄러 job**
|
||||||
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest` → `check_results_for_draw`)
|
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest` → `check_results_for_draw`)
|
||||||
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체)
|
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체)
|
||||||
|
- 월요일 09:00 — weight_evolver_weekly (6개 후보 생성 + 그날 N=5 추출)
|
||||||
|
- 매일 09:00 — weight_evolver_daily (월요일 제외, 오늘 W로 N=5 추출)
|
||||||
|
- 토요일 22:00 — weight_evolver_eval (회고 + 다음주 base 갱신)
|
||||||
|
|
||||||
**lotto-lab API 목록**
|
**lotto-lab API 목록**
|
||||||
|
|
||||||
@@ -162,29 +189,41 @@ docker compose up -d
|
|||||||
| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 |
|
| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 |
|
||||||
| GET | `/api/lotto/recommend/batch` | 배치 추천 |
|
| GET | `/api/lotto/recommend/batch` | 배치 추천 |
|
||||||
| POST | `/api/lotto/recommend/batch` | 배치 추천 저장 |
|
| POST | `/api/lotto/recommend/batch` | 배치 추천 저장 |
|
||||||
|
| GET | `/api/lotto/recommend/smart` | 전략 진화 기반 메타 추천 |
|
||||||
|
| GET | `/api/lotto/purchase` | 구매 이력 조회 (is_real, strategy, draw_no, days 필터) |
|
||||||
|
| POST | `/api/lotto/purchase` | 구매 등록 (실제/가상, 번호, 전략 출처 포함) |
|
||||||
|
| PUT | `/api/lotto/purchase/{id}` | 구매 이력 수정 |
|
||||||
|
| DELETE | `/api/lotto/purchase/{id}` | 구매 이력 삭제 |
|
||||||
|
| GET | `/api/lotto/purchase/stats` | 구매 통계 (전체/실제/가상 + 전략별) |
|
||||||
|
| GET | `/api/lotto/strategy/weights` | 전략별 가중치 + 성과 + trend |
|
||||||
|
| GET | `/api/lotto/strategy/performance` | 전략별 회차 성과 이력 (차트용) |
|
||||||
|
| POST | `/api/lotto/strategy/evolve` | 수동 가중치 재계산 |
|
||||||
| POST | `/api/admin/simulate` | 시뮬레이션 수동 실행 |
|
| POST | `/api/admin/simulate` | 시뮬레이션 수동 실행 |
|
||||||
| POST | `/api/admin/sync_latest` | 당첨번호 수동 동기화 |
|
| POST | `/api/admin/sync_latest` | 당첨번호 수동 동기화 |
|
||||||
| GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) |
|
| GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) |
|
||||||
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
|
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
|
||||||
| DELETE | `/api/history/{id}` | 삭제 |
|
| DELETE | `/api/history/{id}` | 삭제 |
|
||||||
| GET | `/api/todos` | 투두 전체 목록 |
|
| GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 피처 |
|
||||||
| POST | `/api/todos` | 투두 생성 (status: todo\|in_progress\|done) |
|
| GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차) |
|
||||||
| PUT | `/api/todos/{id}` | 투두 수정 |
|
| GET | `/api/lotto/curator/usage` | 큐레이터 토큰·비용 집계 |
|
||||||
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
|
| POST | `/api/lotto/briefing` | AI 브리핑 저장 |
|
||||||
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
|
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
|
||||||
| GET | `/api/blog/posts` | 블로그 글 목록 (`{"posts": [...]}`, date DESC) |
|
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
||||||
| POST | `/api/blog/posts` | 블로그 글 생성 (date 미입력 시 오늘) |
|
| GET | `/api/lotto/briefing` | 브리핑 이력 |
|
||||||
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
|
| GET | `/api/lotto/evolver/status` | weight_evolver 이번주 trials + current_base + 진행 상황 |
|
||||||
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
|
| GET | `/api/lotto/evolver/history?weeks=12` | base 변경 이력 |
|
||||||
|
| GET | `/api/lotto/evolver/trials/{week_start}` | 특정 주 6 trials + 채점 결과 |
|
||||||
|
| POST | `/api/lotto/evolver/generate-now` | 수동 트리거 — 이번주 후보 생성 |
|
||||||
|
| POST | `/api/lotto/evolver/evaluate-now` | 수동 회고 + 다음주 base 갱신 |
|
||||||
|
|
||||||
### stock-lab (stock-lab/)
|
### stock (stock/)
|
||||||
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
|
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
|
||||||
- KIS API 연동으로 실계좌 잔고·거래 조회
|
- KIS API 연동으로 실계좌 잔고·거래 조회
|
||||||
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트
|
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트
|
||||||
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
|
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
|
||||||
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json`
|
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json`
|
||||||
|
|
||||||
**stock-lab API 목록**
|
**stock API 목록**
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
@@ -225,45 +264,462 @@ docker compose up -d
|
|||||||
- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`)
|
- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`)
|
||||||
|
|
||||||
### music-lab (music-lab/)
|
### music-lab (music-lab/)
|
||||||
- AI 음악 생성 서비스. Windows AI 서버(`MUSIC_AI_SERVER_URL`)에 생성 요청 프록시
|
- 듀얼 프로바이더 음악 생성 서비스 (Suno API + 로컬 MusicGen) + YouTube 영상 제작 + 시장 조사 트렌드
|
||||||
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
|
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
|
||||||
- DB: `/app/data/music.db` (music_tasks, music_library 테이블)
|
- 생성된 영상 파일: `/app/data/videos/` (Nginx가 `/media/videos/`로 직접 서빙)
|
||||||
- 파일 구조: `main.py`, `db.py`
|
- DB: `/app/data/music.db` (music_tasks, music_library, video_projects, revenue_records, market_trends, trend_reports 테이블)
|
||||||
- 생성 흐름: POST generate → task_id 반환 → BackgroundTask가 AI 서버 호출 → 파일 저장 → 라이브러리 자동 등록
|
- 파일 구조: `main.py`, `db.py`, `suno_provider.py`, `local_provider.py`, `video_producer.py`, `market.py`
|
||||||
|
- 생성 흐름: POST generate (provider 지정) → task_id 반환 → BackgroundTask → 파일 저장 → 라이브러리 자동 등록
|
||||||
|
|
||||||
|
**Provider 구조**
|
||||||
|
- `suno`: Suno REST API (`apicast.suno.ai/v1`) — 보컬·가사·인스트루멘탈 지원
|
||||||
|
- `local`: Windows AI 서버 (MusicGen) — 인스트루멘탈 전용
|
||||||
|
|
||||||
**music-lab API 목록**
|
**music-lab API 목록**
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| POST | `/api/music/generate` | 음악 생성 시작 (task_id 반환, 비동기) |
|
| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
|
||||||
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 (queued→processing→succeeded/failed) |
|
| GET | `/api/music/models` | Suno 모델 목록 (V4~V5.5) |
|
||||||
|
| GET | `/api/music/credits` | Suno 크레딧 조회 |
|
||||||
|
| POST | `/api/music/generate` | 음악 생성 (provider, model, vocal_gender, negative_tags, style_weight, audio_weight) |
|
||||||
|
| GET | `/api/music/status/{task_id}` | 생성 상태 폴링 |
|
||||||
|
| POST | `/api/music/lyrics` | Suno AI 가사 생성 |
|
||||||
| GET | `/api/music/library` | 라이브러리 전체 조회 |
|
| GET | `/api/music/library` | 라이브러리 전체 조회 |
|
||||||
| POST | `/api/music/library` | 트랙 수동 추가 (201) |
|
| POST | `/api/music/library` | 트랙 수동 추가 |
|
||||||
| DELETE | `/api/music/library/{id}` | 트랙 삭제 (로컬 파일 포함) |
|
| DELETE | `/api/music/library/{id}` | 트랙 삭제 |
|
||||||
|
| POST | `/api/music/extend` | 곡 연장 |
|
||||||
|
| POST | `/api/music/vocal-removal` | 보컬/인스트 분리 (2트랙) |
|
||||||
|
| POST | `/api/music/cover-image` | 커버 이미지 2장 생성 |
|
||||||
|
| POST | `/api/music/wav` | WAV 고음질 변환 |
|
||||||
|
| POST | `/api/music/stem-split` | 12스템 분리 (50cr) |
|
||||||
|
| GET | `/api/music/timestamped-lyrics` | 타임스탬프 가사 (가라오케) |
|
||||||
|
| POST | `/api/music/style-boost` | AI 스타일 프롬프트 생성 |
|
||||||
|
| POST | `/api/music/upload-cover` | 외부 음원 AI Cover |
|
||||||
|
| POST | `/api/music/upload-extend` | 외부 음원 확장 |
|
||||||
|
| POST | `/api/music/add-vocals` | 인스트에 AI 보컬 추가 |
|
||||||
|
| POST | `/api/music/add-instrumental` | 보컬에 AI 반주 추가 |
|
||||||
|
| POST | `/api/music/video` | 뮤직비디오 MP4 생성 |
|
||||||
|
| GET | `/api/music/lyrics/library` | 저장된 가사 목록 |
|
||||||
|
| POST | `/api/music/lyrics/library` | 가사 저장 |
|
||||||
|
| PUT | `/api/music/lyrics/library/{id}` | 가사 수정 |
|
||||||
|
| DELETE | `/api/music/lyrics/library/{id}` | 가사 삭제 |
|
||||||
|
| POST | `/api/music/video-project` | 영상 프로젝트 생성 (track_id, format, target_countries) |
|
||||||
|
| GET | `/api/music/video-projects` | 영상 프로젝트 목록 |
|
||||||
|
| GET | `/api/music/video-project/{id}` | 영상 프로젝트 상세 |
|
||||||
|
| POST | `/api/music/video-project/{id}/render` | FFmpeg 렌더링 시작 (BackgroundTask) |
|
||||||
|
| GET | `/api/music/video-project/{id}/export` | 내보내기 패키지 (mp4+thumbnail+metadata.json) |
|
||||||
|
| DELETE | `/api/music/video-project/{id}` | 영상 프로젝트 삭제 |
|
||||||
|
| GET | `/api/music/revenue/dashboard` | 수익 대시보드 (총수익·조회수·가중평균 RPM) |
|
||||||
|
| GET | `/api/music/revenue` | 수익 기록 목록 |
|
||||||
|
| POST | `/api/music/revenue` | 수익 기록 추가 (UNIQUE: yt_video_id+record_month+country) |
|
||||||
|
| PUT | `/api/music/revenue/{id}` | 수익 기록 수정 |
|
||||||
|
| DELETE | `/api/music/revenue/{id}` | 수익 기록 삭제 |
|
||||||
|
| POST | `/api/music/market/ingest` | agent-office 트렌드 수신 + 리포트 생성 |
|
||||||
|
| GET | `/api/music/market/trends` | 트렌드 조회 (country, genre, source, days=7) |
|
||||||
|
| GET | `/api/music/market/report/latest` | 최신 트렌드 리포트 |
|
||||||
|
| GET | `/api/music/market/report` | 트렌드 리포트 목록 (limit=10) |
|
||||||
|
| GET | `/api/music/market/suggest` | Suno 프롬프트 추천 (limit=5) |
|
||||||
|
|
||||||
**환경변수**
|
**환경변수**
|
||||||
- `MUSIC_AI_SERVER_URL`: AI 음악 생성 서버 URL (미설정 시 생성 요청 실패)
|
- `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화)
|
||||||
|
- `MUSIC_AI_SERVER_URL`: 로컬 MusicGen 서버 URL (미설정 시 local provider 비활성화)
|
||||||
- `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`)
|
- `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/music`)
|
||||||
- `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`)
|
- `MUSIC_DATA_PATH`: NAS 오디오 파일 저장 경로 (기본 `./data/music`)
|
||||||
|
- `PEXELS_API_KEY`: Pexels 스톡 이미지 API 키 (미설정 시 슬라이드쇼 Pexels 이미지 비활성화)
|
||||||
|
- `ANTHROPIC_API_KEY`: Claude Haiku — YouTube 메타데이터 생성 + 시장 인사이트 (미설정 시 폴백 텍스트)
|
||||||
|
- `VIDEO_DATA_DIR`: 영상 파일 저장 경로 (기본 `/app/data/videos`)
|
||||||
|
|
||||||
**AI 서버 응답 형식 (2가지 모두 지원)**
|
**video_projects 테이블**
|
||||||
- binary audio (Content-Type: audio/*) → 직접 저장
|
- format: `visualizer` | `slideshow`
|
||||||
- JSON `{"audio_url": "..."}` → 해당 URL에서 다운로드 후 저장
|
- status: `pending` → `rendering` → `done` | `failed`
|
||||||
|
- target_countries: JSON 배열 (예: `["BR","US"]`)
|
||||||
|
- render_params: JSON 객체 (FFmpeg 파라미터 캐시)
|
||||||
|
|
||||||
|
**revenue_records 테이블**
|
||||||
|
- UNIQUE(yt_video_id, record_month, country)
|
||||||
|
- avg_rpm 계산: 가중평균 `SUM(revenue_usd)/SUM(views)*1000` (단순 AVG 아님)
|
||||||
|
|
||||||
|
**market_trends 테이블**
|
||||||
|
- source: `youtube` | `google_trends` | `billboard`
|
||||||
|
- metadata: JSON 객체 (원본 API 응답 부분)
|
||||||
|
- 인덱스: `idx_mt_country_source` ON (country, source, collected_at DESC)
|
||||||
|
|
||||||
|
**trend_reports 테이블**
|
||||||
|
- report_date UNIQUE — 같은 날 두 번 ingest 시 upsert
|
||||||
|
- top_genres: JSON 배열 `[{genre, score, countries}]` (최대 10개, score 내림차순)
|
||||||
|
- recommended_styles: JSON 배열 `[{genre, suno_prompt, target_countries, reason}]` (최대 5개)
|
||||||
|
|
||||||
|
**music_library 테이블 (확장 컬럼)**
|
||||||
|
- `provider`: `suno` | `local` — 생성에 사용된 프로바이더
|
||||||
|
- `lyrics`: Suno 생성 가사 텍스트
|
||||||
|
- `image_url`: Suno 생성 커버 이미지 URL
|
||||||
|
- `suno_id`: Suno 곡 ID (CDN 참조용)
|
||||||
|
- `file_hash`: MD5 해시 (rename 감지용)
|
||||||
|
- `cover_images`: JSON 배열 — 커버 이미지 URL 목록
|
||||||
|
- `wav_url`: WAV 변환 URL
|
||||||
|
- `video_url`: 뮤직비디오 URL
|
||||||
|
- `stem_urls`: JSON 객체 — 12스템 URL 맵
|
||||||
|
|
||||||
|
**Suno 생성 특이사항**
|
||||||
|
- 1회 생성 시 2개 변형(variation) 반환 → 둘 다 라이브러리에 저장
|
||||||
|
- CDN URL(`cdn1.suno.ai`)은 임시 → 반드시 로컬 다운로드 필요
|
||||||
|
- 가사 섹션 태그: `[Verse]`, `[Chorus]`, `[Bridge]`, `[Instrumental]` 등
|
||||||
|
|
||||||
|
### realestate-lab (realestate-lab/)
|
||||||
|
- 공공데이터포털 API 연동: 한국부동산원 청약홈 분양정보 조회 + 자치구 5티어 매칭 + agent-office push 알림
|
||||||
|
- DB: `/app/data/realestate.db` (announcements, announcement_models, user_profile, match_results, collect_log 테이블)
|
||||||
|
- 파일 구조: `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py`
|
||||||
|
|
||||||
|
**환경변수**
|
||||||
|
- `DATA_GO_KR_API_KEY`: 공공데이터포털 API 키 (미설정 시 수동 등록만 가능)
|
||||||
|
- `AGENT_OFFICE_URL`: agent-office 내부 URL (기본 `http://agent-office:8000`) — 신규 매칭 push 대상
|
||||||
|
- `REALESTATE_NOTIFY_TIMEOUT`: agent-office push timeout 초 (기본 15)
|
||||||
|
|
||||||
|
**스케줄러 job (`scheduled_collect` 4단계 흐름)**
|
||||||
|
- 09:00 매일 — `collect → cleanup → match → notify`
|
||||||
|
1. `collect_all()` — 모집공고일 30일 윈도우(`RCRIT_PBLANC_DE_FROM`) 사전 좁힘 + 자치구 추출 + status='완료' skip
|
||||||
|
2. `delete_old_completed_announcements(grace_days=90)` — `winner_date + 90일` 경과한 완료 공고 정리 (FK CASCADE로 match_results도 삭제)
|
||||||
|
3. `run_matching()` — 자치구 5티어 가중치 + 자격 곡선 적용
|
||||||
|
4. `notify_new_matches()` — `notified_at IS NULL AND match_score >= profile.min_match_score AND profile.notify_enabled`인 매칭을 agent-office로 push
|
||||||
|
- 00:00 매일 — 상태 갱신 + 재매칭 (`scheduled_status_update`, notifier 미호출)
|
||||||
|
|
||||||
|
**매칭 점수 모델 (총 100점)**
|
||||||
|
- 지역 35점 — 광역 매칭 시 10점 + 자치구 5티어 가중치(S=25 / A=20 / B=15 / C=10 / D=5)
|
||||||
|
- `preferred_districts`가 모든 티어 비어있으면 광역 매칭만으로 35점 풀 점수 (legacy 호환)
|
||||||
|
- 주택유형 10점 — `preferred_types`에 매칭 (binary)
|
||||||
|
- 면적 15점 — `[min_area, max_area]` 범위 안 모델 1개 이상 (binary)
|
||||||
|
- 가격 15점 — `max_price` 이하 모델 1개 이상 (binary)
|
||||||
|
- 자격 25점 — `_check_eligible_types()` 결과 1개 이상이면 15점 + 추가당 5점, 최대 +10
|
||||||
|
- reasons 텍스트 예시: `"자치구 S티어: 강남구 (+25)"`, `"광역 일치: 서울"`, `"선호 지역 일치: 서울"` (legacy)
|
||||||
|
|
||||||
|
**user_profile 신규 컬럼 (Task 2026-04-28 마이그레이션)**
|
||||||
|
- `preferred_districts` TEXT — JSON `{"S":[...], "A":[...], "B":[...], "C":[...], "D":[...]}`. default `'{}'`
|
||||||
|
- `min_match_score` INTEGER — 알림 임계값. default 70
|
||||||
|
- `notify_enabled` INTEGER — 알림 ON/OFF. default 1
|
||||||
|
|
||||||
|
**announcements / match_results 신규 컬럼**
|
||||||
|
- `announcements.district` TEXT + `idx_ann_district` 인덱스 — collector가 주소/region_name에서 정규식 파싱
|
||||||
|
- `match_results.notified_at` TEXT NULL — agent-office push 성공 시 timestamp 기록 (멱등 마킹)
|
||||||
|
|
||||||
|
**notifier.py 흐름**
|
||||||
|
1. `get_profile()` → `notify_enabled=False`면 skip, `min_match_score` 가져옴
|
||||||
|
2. `get_unnotified_matches(min_score)` — JOIN으로 announcements 정보 포함 (district, status, receipt 등)
|
||||||
|
3. `POST {AGENT_OFFICE_URL}/api/agent-office/realestate/notify` body=`{"matches": [...]}`
|
||||||
|
4. 응답 `{sent_ids: [...]}` → `mark_matches_notified(sent_ids)` (notified_at = now)
|
||||||
|
5. RequestException 시 마킹 안 함 → 다음 사이클 재시도
|
||||||
|
|
||||||
|
**realestate-lab API 목록**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/realestate/announcements` | 공고 목록. 응답에 `district`, `match_score`, `match_reasons`, `eligible_types` 포함 |
|
||||||
|
| GET | `/api/realestate/announcements/{id}` | 공고 상세 (주택형별 + district 포함) |
|
||||||
|
| POST | `/api/realestate/announcements` | 수동 공고 등록 |
|
||||||
|
| PUT | `/api/realestate/announcements/{id}` | 공고 수정 |
|
||||||
|
| PATCH | `/api/realestate/announcements/{id}/bookmark` | 북마크 토글 (텔레그램 인라인 키보드 콜백 대상) |
|
||||||
|
| DELETE | `/api/realestate/announcements/{id}` | 공고 삭제 |
|
||||||
|
| DELETE | `/api/realestate/announcements/closed` | status='완료' 공고 일괄 삭제 |
|
||||||
|
| POST | `/api/realestate/collect` | 수동 수집 트리거 (collect → cleanup → match → notify 전체 흐름) |
|
||||||
|
| GET | `/api/realestate/collect/status` | 마지막 수집 결과 |
|
||||||
|
| GET | `/api/realestate/profile` | 내 프로필 조회 (`preferred_districts`, `min_match_score`, `notify_enabled` 포함) |
|
||||||
|
| PUT | `/api/realestate/profile` | 프로필 수정 (upsert). body에 `preferred_districts: {S:[],...}`, `min_match_score: 0~100`, `notify_enabled: bool` 수용 |
|
||||||
|
| GET | `/api/realestate/matches` | 매칭 결과 목록 (응답에 `district`, `status` 포함) |
|
||||||
|
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
|
||||||
|
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 처리 |
|
||||||
|
| GET | `/api/realestate/dashboard` | 요약 (진행중 공고수, 신규 매칭수, 다가오는 일정) |
|
||||||
|
|
||||||
### travel-proxy (travel-proxy/)
|
### travel-proxy (travel-proxy/)
|
||||||
- 원본 사진: `/data/travel/` (RO)
|
- 원본 사진: `/data/travel/` (RO)
|
||||||
- 썸네일 캐시: `/data/thumbs/` (RW)
|
- 썸네일 캐시: `/data/thumbs/` (RW)
|
||||||
|
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||||
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
|
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
|
||||||
- 썸네일: 480×480 리사이징 (Pillow), 온디맨드 생성 후 영구 캐시
|
- 지역 오버라이드: `/data/thumbs/region_map_extra.json` (RW, `_regions_meta` 포함)
|
||||||
- 메모리 캐시: TTL 300초 (앨범 스캔 결과)
|
- 파일 구조: `main.py`, `db.py`, `indexer.py`
|
||||||
|
- 썸네일: 480×480 리사이징 (Pillow), 동기화 시 사전 생성 + 온디맨드 폴백
|
||||||
|
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
|
||||||
|
|
||||||
|
**travel.db 테이블**
|
||||||
|
|
||||||
|
| 테이블 | 설명 |
|
||||||
|
|--------|------|
|
||||||
|
| `photos` | 사진 인덱스 (album, filename, mtime, has_thumb) |
|
||||||
|
| `album_covers` | 앨범별 커버 사진 지정 |
|
||||||
|
|
||||||
|
**지역 관리 아키텍처**
|
||||||
|
- `region_map.json` (RO): 원본 지역→앨범 매핑 (`_meta/` 안에 위치)
|
||||||
|
- `region_map_extra.json` (RW): 사용자 수정분 오버라이드 (앨범 이동, 신규 지역)
|
||||||
|
- `_regions_meta`: 커스텀 지역의 이름·좌표 저장 (`{ "region_id": { "name": "...", "coordinates": [lng, lat] } }`)
|
||||||
|
- `regions.geojson` (RO): GeoJSON Polygon 지역 경계
|
||||||
|
- 커스텀 지역: `GET /api/travel/regions`에서 `region_map`에 있지만 GeoJSON에 없는 지역을 자동 추가 (Point geometry 또는 null)
|
||||||
|
|
||||||
**travel-proxy API 목록**
|
**travel-proxy API 목록**
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/api/travel/regions` | 지역 GeoJSON |
|
| GET | `/api/travel/regions` | 지역 GeoJSON (커스텀 지역 동적 추가 포함) |
|
||||||
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
|
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
|
||||||
| POST | `/api/travel/reload` | 메모리 캐시 초기화 |
|
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
|
||||||
|
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 + region/regionName |
|
||||||
|
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
|
||||||
|
| PUT | `/api/travel/albums/{album}/region` | 앨범 지역 변경 (region_map_extra 수정) |
|
||||||
|
| PUT | `/api/travel/regions/{region_id}` | 커스텀 지역 이름/좌표 수정 (지도 핀 표시용) |
|
||||||
|
|
||||||
|
### insta-lab (insta-lab/)
|
||||||
|
- 인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피 + PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드
|
||||||
|
- DB: `/app/data/insta.db` (news_articles, trending_keywords, card_slates, card_assets, generation_tasks, prompt_templates)
|
||||||
|
- 카드 사이즈: 1080×1350 (인스타 4:5 세로)
|
||||||
|
- 카드 렌더: Jinja2 템플릿 → Playwright headless Chromium 스크린샷
|
||||||
|
- 파일 구조: `app/main.py`, `config.py`, `db.py`, `news_collector.py`, `keyword_extractor.py`, `card_writer.py`, `card_renderer.py`, `templates/default/card.html.j2`
|
||||||
|
|
||||||
|
**환경변수**
|
||||||
|
- `NAVER_CLIENT_ID` / `NAVER_CLIENT_SECRET`: 네이버 검색 API
|
||||||
|
- `ANTHROPIC_API_KEY`: Claude API (Haiku=키워드 정제, Sonnet=카드 카피)
|
||||||
|
- `ANTHROPIC_MODEL_HAIKU` / `ANTHROPIC_MODEL_SONNET`: 모델명 오버라이드
|
||||||
|
- `INSTA_DATA_PATH`: SQLite + 카드 PNG 저장 경로 (기본 `/app/data`)
|
||||||
|
- `CARD_TEMPLATE_DIR`: HTML 템플릿 디렉토리 (기본 `/app/app/templates`)
|
||||||
|
- `INSTA_DEFAULT_THEME`: 카드 렌더에 사용할 theme 디렉토리명 (기본 `default`). `templates/<theme>/card.html.j2`가 없으면 자동으로 default 폴백
|
||||||
|
- `NEWS_PER_CATEGORY` / `KEYWORDS_PER_CATEGORY`: 수집·추출 limit 튜닝
|
||||||
|
|
||||||
|
**카테고리 시드 키워드**
|
||||||
|
- 기본 economy / psychology / celebrity 3종 (config.DEFAULT_CATEGORY_SEEDS)
|
||||||
|
- `prompt_templates.name='category_seeds'`에 JSON으로 오버라이드 가능
|
||||||
|
|
||||||
|
**카드 슬레이트 (`card_slates`)**
|
||||||
|
- status: `draft` → `rendered` → `sent` (또는 `failed`)
|
||||||
|
- cover_copy / body_copies (8개) / cta_copy / suggested_caption / hashtags JSON 컬럼
|
||||||
|
- accent_color는 카테고리별 기본값 (economy=#0F62FE, psychology=#A66CFF, celebrity=#FF5C8A)
|
||||||
|
|
||||||
|
**스케줄러 job (agent-office)**
|
||||||
|
- 09:30 매일 — `_run_insta_schedule` (insta_pipeline) → 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시
|
||||||
|
- `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송
|
||||||
|
|
||||||
|
**디자인 import (사용자 디자인 PNG → Claude Vision → Jinja HTML 자동 생성)**
|
||||||
|
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 4:5 비율 권장 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
|
||||||
|
- 파일명 자동 매핑: `cover`/`start`/`intro` → page 1, `cta`/`outro`/`finish`/`end` → page 10, 나머지 알파벳 순 → page 2~9
|
||||||
|
- 매핑 override: `pages/_order.json`에 `{filename: page_no}` 명시 (10장 + page 1~10 완전 매핑일 때만 적용)
|
||||||
|
- Vision prompt에 placeholder 마스킹 요구 포함 (2-layer: 마스킹 박스 + 동적 텍스트 layer)
|
||||||
|
- 기존 HTML 자동 백업 (`card.html.j2.bak.YYYYMMDD-HHMMSS`)
|
||||||
|
- Jinja 문법 깨진 응답은 `card.html.j2.error.txt`로 보존 + ValueError
|
||||||
|
- 활성화: `.env`에 `INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab` (테마 디렉토리에 `card.html.j2` 없으면 렌더러가 default로 폴백)
|
||||||
|
- 토큰 비용: 1회당 ~15K tokens (~$0.05 Sonnet 기준)
|
||||||
|
|
||||||
|
**⚠️ 실행 위치 — 로컬 권장, NAS docker exec 금지**
|
||||||
|
- docker-compose의 insta-lab volume은 `/app/data`만 마운트. **`/app/app/templates`는 컨테이너 ephemeral state**.
|
||||||
|
- NAS에서 `docker exec insta-lab python -m app.design_importer <theme>`로 돌리면 `card.html.j2`가 컨테이너 안에만 생성되고 다음 image rebuild(다른 push의 webhook이라도) 때 사라짐 → 렌더러가 default로 폴백.
|
||||||
|
- **로컬 실행** (host repo working tree에 영속화 → git push → 자동 배포):
|
||||||
|
```bash
|
||||||
|
cd insta-lab
|
||||||
|
pip install anthropic Pillow jinja2 # 이미 있으면 skip
|
||||||
|
export ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
python -m app.design_importer <theme> --templates-dir ./app/templates
|
||||||
|
git add app/templates/<theme>/card.html.j2
|
||||||
|
git commit -m "feat(insta-lab): <theme> 디자인 import"
|
||||||
|
git push # → Gitea webhook → NAS rebuild → 영구 활성화
|
||||||
|
```
|
||||||
|
- 응급 hotfix로 NAS에서 돌렸다면 `docker cp insta-lab:/app/app/templates/<theme>/card.html.j2 ./` 후 즉시 host repo에 commit + push 필요
|
||||||
|
|
||||||
|
**insta-lab API 목록**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/insta/status` | 서비스 상태 (NAVER/ANTHROPIC 키 여부) |
|
||||||
|
| POST | `/api/insta/news/collect` | 뉴스 수집 트리거 (BackgroundTask) |
|
||||||
|
| GET | `/api/insta/news/articles` | 수집 기사 목록 (category, days) |
|
||||||
|
| POST | `/api/insta/keywords/extract` | 키워드 추출 트리거 (BackgroundTask) |
|
||||||
|
| GET | `/api/insta/keywords` | 트렌딩 키워드 목록 (category, used) |
|
||||||
|
| POST | `/api/insta/slates` | 슬레이트 생성 (keyword, category) |
|
||||||
|
| GET | `/api/insta/slates` | 슬레이트 목록 |
|
||||||
|
| GET | `/api/insta/slates/{id}` | 슬레이트 상세 + 자산 |
|
||||||
|
| POST | `/api/insta/slates/{id}/render` | 카드 렌더 재시도 |
|
||||||
|
| GET | `/api/insta/slates/{id}/assets/{page}` | 카드 PNG 다운로드 (1~10) |
|
||||||
|
| DELETE | `/api/insta/slates/{id}` | 슬레이트 삭제 (자산 파일 포함) |
|
||||||
|
| GET | `/api/insta/tasks/{task_id}` | BackgroundTask 상태 폴링 |
|
||||||
|
| GET/PUT | `/api/insta/templates/prompts/{name}` | 프롬프트 템플릿 CRUD |
|
||||||
|
|
||||||
|
### agent-office (agent-office/)
|
||||||
|
- AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 에이전트가 실제 작업 수행
|
||||||
|
- stock/music-lab/realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||||
|
- 실시간 상태 동기화: WebSocket (`/api/agent-office/ws`)
|
||||||
|
- 텔레그램 봇: 양방향 알림 + 승인 (인라인 키보드)
|
||||||
|
- 청약 매칭 알림: realestate-lab이 신규 매칭 발견 시 push → `RealestateAgent.on_new_matches()` → 텔레그램 1통(인라인 [🔖 북마크]/[📄 공고] 또는 [전체 보기] 버튼)
|
||||||
|
- DB: `/app/data/agent_office.db` (agent_config, agent_tasks, agent_logs, telegram_state 테이블)
|
||||||
|
- 파일 구조: `main.py`, `db.py`, `config.py`, `models.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/base.py`, `agents/stock.py`, `agents/music.py`, `agents/realestate.py`, `telegram/realestate_message.py`
|
||||||
|
|
||||||
|
**에이전트 FSM 상태**: idle → working → waiting (승인 대기) → reporting → break (휴식)
|
||||||
|
|
||||||
|
**환경변수**
|
||||||
|
- `STOCK_URL`: stock 내부 URL (기본 `http://stock:8000`)
|
||||||
|
- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
|
||||||
|
- `REALESTATE_LAB_URL`: realestate-lab 내부 URL (기본 `http://realestate-lab:8000`) — 북마크 콜백 프록시 대상
|
||||||
|
- `REALESTATE_DASHBOARD_URL`: 텔레그램 [전체 보기] 버튼 URL (기본 `http://localhost:8080/realestate`)
|
||||||
|
- `TELEGRAM_BOT_TOKEN`: 텔레그램 봇 토큰 (미설정 시 알림 비활성화)
|
||||||
|
- `TELEGRAM_CHAT_ID`: 텔레그램 채팅 ID
|
||||||
|
- `TELEGRAM_WEBHOOK_URL`: 텔레그램 Webhook URL
|
||||||
|
- `TELEGRAM_WIFE_CHAT_ID`: 아내 chat.id (브리핑 공유 + 대화 허용)
|
||||||
|
- `ANTHROPIC_API_KEY`: 자연어 대화용 Claude API 키 (미설정 시 대화 비활성)
|
||||||
|
- `CONVERSATION_MODEL`: 대화 모델 (기본 `claude-haiku-4-5-20251001`)
|
||||||
|
- `CONVERSATION_HISTORY_LIMIT`: 이력 주입 수 (기본 20)
|
||||||
|
- `CONVERSATION_RATE_PER_MIN`: 채팅당 분당 최대 메시지 (기본 6)
|
||||||
|
- `LOTTO_BACKEND_URL`: 기본 `http://lotto:8000`
|
||||||
|
- `LOTTO_CURATOR_MODEL`: 기본 `claude-sonnet-4-5`
|
||||||
|
- `YOUTUBE_DATA_API_KEY`: YouTube Data API v3 키 (미설정 시 YouTube trending 수집 skip)
|
||||||
|
- `LOTTO_SIGNAL_WINDOW`: baseline 윈도우 크기 (기본 8)
|
||||||
|
- `LOTTO_Z_NORMAL`: normal fire 임계치 (기본 1.5)
|
||||||
|
- `LOTTO_Z_URGENT`: urgent fire 임계치 (기본 2.5)
|
||||||
|
- `LOTTO_THROTTLE_HOURS`: 같은 메트릭 재발화 throttle (기본 6시간)
|
||||||
|
- `LOTTO_URGENT_DAILY_MAX`: urgent 하루 cap (기본 3통)
|
||||||
|
|
||||||
|
**YouTubeResearchAgent (`agents/youtube.py`)**
|
||||||
|
- `agent_id = "youtube"` — AGENT_REGISTRY에 등록
|
||||||
|
- 09:00 매일 `on_schedule()` → 국가별 YouTube 트렌딩 + Google Trends + Billboard Top20 수집 → music-lab push
|
||||||
|
- `on_command("research", {countries: []})` → 수동 트리거 (백그라운드 asyncio.create_task)
|
||||||
|
- 수집 소스: `youtube_researcher.py` (fetch_youtube_trending, fetch_google_trends, fetch_billboard_top20)
|
||||||
|
- DB: `youtube_research_jobs` 테이블에 실행 이력 기록
|
||||||
|
- 동시실행 방지: `self.state == "working"` 체크 후 거부
|
||||||
|
- 월요일 08:00 `send_weekly_report()` → music-lab 최신 리포트 → 텔레그램 발송
|
||||||
|
|
||||||
|
**텔레그램 자연어 대화 (옵션 B)**
|
||||||
|
- 슬래시 명령이 아닌 일반 문장을 보내면 Claude Haiku 4.5가 응답
|
||||||
|
- 프롬프트 캐싱: `system` 블록 + 히스토리 마지막 블록에 `cache_control: ephemeral` → 5분 TTL
|
||||||
|
- 허용 chat_id 화이트리스트: `TELEGRAM_CHAT_ID`, `TELEGRAM_WIFE_CHAT_ID`
|
||||||
|
- 평가 지표: `conversation_messages` 테이블에 tokens / cache_read / cache_write / latency 기록
|
||||||
|
- 조회: `GET /api/agent-office/conversation/stats?days=7`
|
||||||
|
|
||||||
|
**스케줄러 job**
|
||||||
|
- 07:30 매일 — 주식 뉴스 요약 (`stock_news_job`)
|
||||||
|
- 매주 월요일 07:00 — 로또 큐레이터 브리핑 (`lotto_curate`)
|
||||||
|
- 60초 간격 — 유휴 에이전트 휴식 체크 (`idle_check_job`)
|
||||||
|
- ~~09:15 매일 — 청약 매칭 데일리 리포트~~ (Task 2026-04-28에서 폐기. realestate-lab의 push 트리거로 전환)
|
||||||
|
- 09:00 매일 — YouTube 트렌드 수집 (`youtube_research`) → music-lab `/api/music/market/ingest` push
|
||||||
|
- 매주 월요일 08:00 — YouTube 주간 리포트 텔레그램 발송 (`youtube_weekly_report`)
|
||||||
|
- 09:15 매일 — 로또 light_check (시뮬·전략 가중치 평가)
|
||||||
|
- 매 4시간 :15 — 로또 sim_check (00/04/08/12/16/20시)
|
||||||
|
- 일/수 21:15 — 로또 deep_check (큐레이션 후 confidence 포함 평가)
|
||||||
|
- 09:25 매일 — 로또 daily_digest (지난 24h 발화 텔레그램 1통)
|
||||||
|
- 토요일 22:15 — 로또 weight_evolver 주간 텔레그램 리포트
|
||||||
|
|
||||||
|
**RealestateAgent (`agents/realestate.py`)**
|
||||||
|
- 진입점: `on_new_matches(matches: list[dict]) -> {sent, sent_ids, message_id}`
|
||||||
|
- realestate-lab의 push에서 트리거 → `format_realestate_matches()` + `build_match_keyboard()` → `messaging.send_raw()`
|
||||||
|
- 1~2건이면 풀 카드 + [🔖 북마크]/[📄 공고 보기] 행씩, 3건 이상이면 묶음 카드 + [📋 전체 보기] 단일 URL 버튼
|
||||||
|
- 인라인 키보드 콜백 `realestate_bookmark_{id}` → `webhook.py`의 `_handle_realestate_bookmark` → `service_proxy.realestate_bookmark_toggle()` → realestate-lab의 `PATCH /announcements/{id}/bookmark`
|
||||||
|
- 송신 성공 시 sent_ids 반환 → realestate-lab이 match_results.notified_at 마킹 (멱등)
|
||||||
|
- 실패 시 sent=0/sent_ids=[]/error 반환 → 마킹 안 됨 → 다음 사이클 재시도
|
||||||
|
- `on_command("fetch_matches")`: 수동 트리거 — service_proxy로 매치 가져와 `on_new_matches` 호출
|
||||||
|
- `on_schedule`: 폐기 (cron 등록 제거됨)
|
||||||
|
|
||||||
|
**agent-office API 목록**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| WS | `/api/agent-office/ws` | WebSocket (init, agent_state, task_complete, command_result) |
|
||||||
|
| GET | `/api/agent-office/agents` | 에이전트 목록 |
|
||||||
|
| GET | `/api/agent-office/agents/{id}` | 에이전트 상세 (설정 + 상태) |
|
||||||
|
| PUT | `/api/agent-office/agents/{id}` | 에이전트 설정 수정 |
|
||||||
|
| GET | `/api/agent-office/agents/{id}/tasks` | 에이전트 작업 이력 |
|
||||||
|
| GET | `/api/agent-office/agents/{id}/logs` | 에이전트 로그 |
|
||||||
|
| GET | `/api/agent-office/tasks/pending` | 승인 대기 작업 목록 |
|
||||||
|
| GET | `/api/agent-office/tasks/{id}` | 작업 상세 |
|
||||||
|
| POST | `/api/agent-office/command` | 에이전트에 명령 전송 |
|
||||||
|
| POST | `/api/agent-office/approve` | 작업 승인/거부 |
|
||||||
|
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook 수신 (realestate_bookmark_* 콜백 포함) |
|
||||||
|
| POST | `/api/agent-office/realestate/notify` | realestate-lab 전용 push 수신 → 텔레그램 송신 |
|
||||||
|
| GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 |
|
||||||
|
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
|
||||||
|
| POST | `/api/agent-office/youtube/research` | YouTube 트렌드 수집 수동 트리거 (body: `{countries: []}`) |
|
||||||
|
| GET | `/api/agent-office/youtube/research/status` | 마지막 수집 작업 상태 |
|
||||||
|
| GET | `/api/agent-office/lotto/signals?days=7` | 로또 능동 시그널 이력 (모든 fire_level) |
|
||||||
|
| GET | `/api/agent-office/lotto/baselines` | 로또 메트릭별 baseline μ/σ + 윈도우 상태 |
|
||||||
|
| POST | `/api/agent-office/lotto/signal-check?source=light` | 로또 시그널 평가 수동 트리거 (light/sim/deep) |
|
||||||
|
|
||||||
|
### personal (personal/)
|
||||||
|
- 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)
|
||||||
|
- DB: `/app/data/personal.db` (profile, careers, projects, skills, introductions, todos, blog_posts 테이블)
|
||||||
|
- 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL)
|
||||||
|
- 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py`
|
||||||
|
|
||||||
|
**환경변수**
|
||||||
|
- `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가)
|
||||||
|
|
||||||
|
**personal API 목록**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/profile/public` | 공개 데이터 일괄 조회 |
|
||||||
|
| POST | `/api/profile/auth` | 비밀번호 인증 → 토큰 |
|
||||||
|
| GET | `/api/profile/profile` | 프로필 조회 (인증) |
|
||||||
|
| PUT | `/api/profile/profile` | 프로필 수정 (인증) |
|
||||||
|
| GET | `/api/profile/careers` | 경력 목록 (인증) |
|
||||||
|
| POST | `/api/profile/careers` | 경력 추가 (인증) |
|
||||||
|
| PUT | `/api/profile/careers/{id}` | 경력 수정 (인증) |
|
||||||
|
| DELETE | `/api/profile/careers/{id}` | 경력 삭제 (인증) |
|
||||||
|
| GET | `/api/profile/projects` | 프로젝트 목록 (인증) |
|
||||||
|
| POST | `/api/profile/projects` | 프로젝트 추가 (인증) |
|
||||||
|
| PUT | `/api/profile/projects/{id}` | 프로젝트 수정 (인증) |
|
||||||
|
| DELETE | `/api/profile/projects/{id}` | 프로젝트 삭제 (인증) |
|
||||||
|
| GET | `/api/profile/skills` | 기술 목록 (인증) |
|
||||||
|
| POST | `/api/profile/skills` | 기술 추가 (인증) |
|
||||||
|
| PUT | `/api/profile/skills/{id}` | 기술 수정 (인증) |
|
||||||
|
| DELETE | `/api/profile/skills/{id}` | 기술 삭제 (인증) |
|
||||||
|
| GET | `/api/profile/introductions` | 자기소개 목록 (인증) |
|
||||||
|
| POST | `/api/profile/introductions` | 자기소개 추가 (인증) |
|
||||||
|
| PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) |
|
||||||
|
| DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) |
|
||||||
|
| PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) |
|
||||||
|
| GET | `/api/todos` | 투두 전체 목록 |
|
||||||
|
| POST | `/api/todos` | 투두 생성 |
|
||||||
|
| PUT | `/api/todos/{id}` | 투두 수정 |
|
||||||
|
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
|
||||||
|
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
|
||||||
|
| GET | `/api/blog/posts` | 블로그 글 목록 |
|
||||||
|
| POST | `/api/blog/posts` | 블로그 글 생성 |
|
||||||
|
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
|
||||||
|
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
|
||||||
|
|
||||||
|
### packs-lab (packs-lab/)
|
||||||
|
- NAS 자료 다운로드 자동화 — Synology DSM 공유링크 발급 + 5GB 멀티파트 업로드 수신
|
||||||
|
- Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음)
|
||||||
|
- DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`)
|
||||||
|
- 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py`
|
||||||
|
- 경로 3분리: `PACK_DATA_PATH`(호스트 OS path, docker volume 좌측) → `PACK_BASE_DIR`(컨테이너 내부, upload 저장 target) → `PACK_HOST_DIR`(DSM API path, Supabase에 저장). 운영 NAS에서 `PACK_HOST_DIR` 미설정 시 sign-link가 컨테이너 경로를 DSM에 전달해 파일을 못 찾음.
|
||||||
|
- ⚠️ **DSM API path 형식**: Synology DSM API는 일반 사용자 권한일 때 `/<shared_folder>/...` 형식만 인식하고 `/volume1/...` 절대경로는 거부(error 408). 운영 NAS는 반드시 `PACK_HOST_DIR=/docker/webpage/media/packs` (shared folder 시점) 설정. admin 사용자만 `/volume1/...` 사용 가능하나 보안상 권장 안 함.
|
||||||
|
|
||||||
|
**환경변수**
|
||||||
|
- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||||
|
- `DSM_VERIFY_SSL`: SSL 검증 (default `true`). LAN IP + self-signed cert 환경에서 IP mismatch 시 `false` 설정 (LAN 내부 통신이라 허용)
|
||||||
|
- `BACKEND_HMAC_SECRET`: Vercel SaaS와 양쪽 공유 시크릿 (HMAC SHA256)
|
||||||
|
- `SUPABASE_URL` / `SUPABASE_SERVICE_KEY`: Supabase pack_files 테이블 접근 (service_role, RLS 우회)
|
||||||
|
- `UPLOAD_TOKEN_TTL_SEC`: admin upload 토큰 TTL (기본 1800초 = 30분)
|
||||||
|
- `PACK_BASE_DIR`: 컨테이너 내부 저장 경로 (기본 `/app/data/packs`)
|
||||||
|
- `PACK_HOST_DIR`: DSM API용 path. **운영 NAS는 `/docker/webpage/media/packs` (shared folder 시점)**. 미설정 시 `PACK_BASE_DIR`로 fallback (DSM 호출 X 환경에서만 안전)
|
||||||
|
- `PACK_DATA_PATH`: docker-compose volume 마운트의 호스트 측 OS 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`)
|
||||||
|
|
||||||
|
**HMAC 인증 패턴**
|
||||||
|
- Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret))
|
||||||
|
- Replay 방어: 타임스탬프 ±5분 윈도우
|
||||||
|
- admin browser → backend upload: `Authorization: Bearer <token>` (jti 단발성)
|
||||||
|
|
||||||
|
**packs-lab API 목록**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/packs/sign-link` | Vercel HMAC → DSM Sharing.create로 4시간 유효 다운로드 URL 발급 |
|
||||||
|
| POST | `/api/packs/admin/mint-token` | Vercel HMAC → 일회성 upload 토큰 발급 (기본 30분 TTL) |
|
||||||
|
| POST | `/api/packs/upload` | Bearer token (single-shot) → multipart 5GB 저장 + Supabase INSERT |
|
||||||
|
| POST | `/api/packs/upload/init` | Bearer token → chunked upload 세션 초기화 (`session_id = jti`, `chunk_max_size` 반환). init만 jti consume |
|
||||||
|
| PUT | `/api/packs/upload/{session_id}/chunk?offset=N` | 동일 Bearer token → 부분파일 append (offset 불일치 시 409 + `X-Current-Offset` 헤더) |
|
||||||
|
| GET | `/api/packs/upload/{session_id}/status` | 동일 Bearer token → `{written, expected_size}` 조회 (재개용) |
|
||||||
|
| POST | `/api/packs/upload/{session_id}/complete` | 동일 Bearer token → 부분파일 rename + Supabase INSERT |
|
||||||
|
| DELETE | `/api/packs/upload/{session_id}` | 동일 Bearer token → 세션 중단 + 부분파일 정리 |
|
||||||
|
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
|
||||||
|
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
|
||||||
|
|
||||||
|
**Chunked upload 흐름 (5GB+ 안정성)**
|
||||||
|
- 같은 mint-token을 init·chunk·status·complete·abort 전체에서 Bearer로 재사용 (jti consume은 init에서만)
|
||||||
|
- 세션 state: 컨테이너 내부 `PACK_BASE_DIR/.uploads/{jti}/meta.json + data.part`
|
||||||
|
- chunk 재시도: 클라이언트는 PUT 응답 헤더 `X-Current-Offset` 또는 `GET /status`로 재개 지점 확인
|
||||||
|
- 환경변수 `PACK_CHUNK_MAX_SIZE` (기본 64MB) — 너무 크면 nginx buffering 부담, 너무 작으면 RTT 비용
|
||||||
|
|
||||||
### deployer (deployer/)
|
### deployer (deployer/)
|
||||||
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
|
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
|
||||||
@@ -276,12 +732,13 @@ docker compose up -d
|
|||||||
## 10. 주의사항
|
## 10. 주의사항
|
||||||
|
|
||||||
- **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리
|
- **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리
|
||||||
- **라우트 순서**: `DELETE /api/todos/done`은 `DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (FastAPI prefix 매칭 순서)
|
- **라우트 순서**: `DELETE /api/todos/done`은 `DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (personal 서비스, FastAPI prefix 매칭 순서)
|
||||||
- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입
|
- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입
|
||||||
- **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable)
|
- **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable)
|
||||||
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
|
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
|
||||||
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
|
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||||
- **공휴일 목록**: `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
- **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
||||||
- **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용
|
- **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용
|
||||||
- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`)
|
- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`)
|
||||||
- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력
|
- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력
|
||||||
|
- **insta-lab Playwright**: NAS에서 chromium 빌드는 가능하지만 +500MB 이미지. 메모리 부족 시 카드 렌더 실패 가능 — 한 번에 1슬레이트만 렌더하도록 직렬화됨
|
||||||
|
|||||||
419
README.md
419
README.md
@@ -1,32 +1,45 @@
|
|||||||
# web-backend
|
# web-backend
|
||||||
|
|
||||||
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||||
로또 분석, 주식 포트폴리오, 여행 앨범, 블로그, 투두리스트를 하나의 서비스로 운영한다.
|
로또 분석, 주식 포트폴리오, AI 음악 생성, 인스타 카드 피드, 부동산 청약, AI 에이전트 오피스, 여행 앨범, 개인 서비스(포트폴리오·블로그·투두), NAS 자료 다운로드 자동화를 하나의 Docker Compose 스택으로 운영한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 서비스 구성
|
## 서비스 구성
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
│ lotto-frontend (Nginx:8080) │
|
│ frontend (Nginx:8080) │
|
||||||
│ ├── 정적 SPA 서빙 (React + Vite) │
|
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||||
│ └── API 리버스 프록시 │
|
│ └── API 리버스 프록시 │
|
||||||
│ ├── /api/ → lotto-backend:8000 │
|
│ ├── /api/ → lotto:8000 (로또) │
|
||||||
│ ├── /api/stock/ → stock-lab:8000 │
|
│ ├── /api/stock/, /trade/ → stock:8000 │
|
||||||
│ ├── /api/trade/ → stock-lab:8000 │
|
│ ├── /api/portfolio → stock:8000 │
|
||||||
│ ├── /api/portfolio → stock-lab:8000 │
|
│ ├── /api/music/ → music-lab:8000 │
|
||||||
│ ├── /api/travel/ → travel-proxy:8000 │
|
│ ├── /api/insta/ → insta-lab:8000 │
|
||||||
│ └── /webhook → deployer:9000 │
|
│ ├── /api/realestate/ → realestate-lab:8000 │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
│ ├── /api/agent-office/ → agent-office:8000 (+ WebSocket) │
|
||||||
|
│ ├── /api/profile/, /todos, /blog/ → personal:8000 │
|
||||||
|
│ ├── /api/packs/ → packs-lab:8000 (HMAC + 5GB upload) │
|
||||||
|
│ ├── /api/travel/ → travel-proxy:8000 │
|
||||||
|
│ ├── /media/music/, /media/videos/ (nginx 직접 서빙, 미디어) │
|
||||||
|
│ ├── /media/travel/… (nginx 직접 서빙, 사진/썸네일) │
|
||||||
|
│ └── /webhook → deployer:9000 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
| 컨테이너 | 포트 | 역할 |
|
| 컨테이너 | 포트 | 역할 |
|
||||||
|---------|------|------|
|
|---------|------|------|
|
||||||
| `lotto-backend` | 18000 | 로또·블로그·투두 API |
|
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||||
| `stock-lab` | 18500 | 주식 뉴스·포트폴리오·자산 추적 |
|
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||||
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) + YouTube 수익화 |
|
||||||
| `lotto-frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
| `insta-lab` | 18700 | 인스타 카드 피드 자동 생성 (뉴스→키워드→10페이지 카드, Playwright) |
|
||||||
|
| `realestate-lab` | 18800 | 청약 공고 자동 수집·5티어 매칭·신규 매칭 push |
|
||||||
|
| `agent-office` | 18900 | AI 에이전트 가상 오피스 (WebSocket + 텔레그램 봇) |
|
||||||
|
| `personal` | 18850 | 개인 서비스 — 포트폴리오·블로그·투두 통합 |
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 청크 업로드) |
|
||||||
|
| `travel-proxy` | 19000 | 여행 사진 API + 온디맨드 썸네일 |
|
||||||
|
| `frontend` | 8080 | SPA 서빙 + 리버스 프록시 |
|
||||||
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
| `webpage-deployer` | 19010 | Gitea Webhook → 자동 배포 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -35,47 +48,21 @@ Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
|
|
||||||
```
|
```
|
||||||
web-backend/
|
web-backend/
|
||||||
├── backend/ # lotto-backend 서비스 (Python/FastAPI)
|
├── lotto/ # 로또 추천·통계·시뮬레이션
|
||||||
│ ├── app/
|
├── stock/ # 주식·포트폴리오·KIS 연동
|
||||||
│ │ ├── main.py # 라우터, 스케줄러
|
├── music-lab/ # AI 음악 생성 + YouTube 수익화
|
||||||
│ │ ├── db.py # SQLite CRUD (7개 테이블)
|
├── insta-lab/ # 인스타 카드 피드 자동 생성 (Playwright)
|
||||||
│ │ ├── generator.py # 몬테카를로 시뮬레이션 엔진
|
├── realestate-lab/ # 청약 자동 수집·5티어 매칭
|
||||||
│ │ ├── analyzer.py # 5가지 통계 분석
|
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
||||||
│ │ ├── checker.py # 당첨 결과 채점
|
├── personal/ # 포트폴리오·블로그·투두 통합
|
||||||
│ │ ├── collector.py # 로또 데이터 수집
|
├── packs-lab/ # NAS 자료 다운로드 자동화 (HMAC + Supabase)
|
||||||
│ │ ├── recommender.py # 추천 알고리즘
|
├── travel-proxy/ # 여행 사진 + 썸네일
|
||||||
│ │ └── utils.py # 메트릭 계산
|
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
||||||
│ └── Dockerfile
|
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
||||||
│
|
├── scripts/ # deploy.sh, deploy-nas.sh, healthcheck.sh
|
||||||
├── stock-lab/ # stock-lab 서비스 (Python/FastAPI)
|
|
||||||
│ ├── app/
|
|
||||||
│ │ ├── main.py # 라우터, 스케줄러
|
|
||||||
│ │ ├── db.py # SQLite CRUD (4개 테이블)
|
|
||||||
│ │ ├── scraper.py # 네이버 금융 뉴스 크롤링
|
|
||||||
│ │ ├── price_fetcher.py # 현재가 조회 (3분 캐시)
|
|
||||||
│ │ └── holidays.json # 한국 주식시장 휴장일
|
|
||||||
│ └── Dockerfile
|
|
||||||
│
|
|
||||||
├── travel-proxy/ # travel-proxy 서비스 (Python/FastAPI)
|
|
||||||
│ ├── app/
|
|
||||||
│ │ └── main.py # 사진 API, 썸네일 생성 (Pillow)
|
|
||||||
│ └── Dockerfile
|
|
||||||
│
|
|
||||||
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
|
||||||
│ ├── app.py # HMAC SHA256 검증 + 배포 트리거
|
|
||||||
│ └── Dockerfile
|
|
||||||
│
|
|
||||||
├── nginx/
|
|
||||||
│ └── default.conf # 리버스 프록시 + SPA + 캐시
|
|
||||||
│
|
|
||||||
├── scripts/
|
|
||||||
│ ├── deploy.sh # 운영 배포 (git pull → rsync → compose up)
|
|
||||||
│ ├── deploy-nas.sh # rsync 전용 스크립트
|
|
||||||
│ └── healthcheck.sh # 전체 서비스 헬스 체크
|
|
||||||
│
|
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── .env.example
|
├── .env.example
|
||||||
└── CLAUDE.md
|
└── CLAUDE.md # Claude Code 작업용 상세 컨텍스트
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -83,13 +70,9 @@ web-backend/
|
|||||||
## 빠른 시작 (로컬 개발)
|
## 빠른 시작 (로컬 개발)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 환경변수 설정
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# 2. 컨테이너 실행 (.env 기본값으로 즉시 실행 가능)
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 3. 확인
|
|
||||||
curl http://localhost:18000/health
|
curl http://localhost:18000/health
|
||||||
curl http://localhost:18500/health
|
curl http://localhost:18500/health
|
||||||
```
|
```
|
||||||
@@ -97,108 +80,145 @@ curl http://localhost:18500/health
|
|||||||
| 서비스 | 로컬 URL |
|
| 서비스 | 로컬 URL |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
| Frontend + API | http://localhost:8080 |
|
| Frontend + API | http://localhost:8080 |
|
||||||
| lotto-backend | http://localhost:18000 |
|
| lotto | http://localhost:18000 |
|
||||||
| stock-lab | http://localhost:18500 |
|
| stock | http://localhost:18500 |
|
||||||
|
| music-lab | http://localhost:18600 |
|
||||||
|
| insta-lab | http://localhost:18700 |
|
||||||
|
| realestate-lab | http://localhost:18800 |
|
||||||
|
| personal | http://localhost:18850 |
|
||||||
|
| agent-office | http://localhost:18900 |
|
||||||
|
| packs-lab | http://localhost:18950 |
|
||||||
| travel-proxy | http://localhost:19000 |
|
| travel-proxy | http://localhost:19000 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API 목록
|
## 서비스별 기능
|
||||||
|
|
||||||
### lotto-backend (`/api/`)
|
### 1. lotto-backend (`/api/`)
|
||||||
|
|
||||||
#### 로또
|
로또 당첨번호 수집·통계 분석·몬테카를로 시뮬레이션 기반 추천 + 투두·블로그 CRUD.
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
- **로또**: 당첨번호 조회, 5종 통계 분석, 시뮬레이션 최적 번호(`best_picks` 20쌍), 통계/히트맵/스마트/배치 추천, 전략 가중치(EMA+Softmax), 구매 이력 관리
|
||||||
|--------|------|------|
|
- **추천 이력**: 즐겨찾기·태그·메모 관리
|
||||||
| GET | `/api/lotto/latest` | 최신 당첨번호 |
|
- **투두리스트**: UUID PK, 상태(todo/in_progress/done)
|
||||||
| GET | `/api/lotto/{drw_no}` | 특정 회차 |
|
- **블로그**: 일기형 포스트 (tags JSON 배열, date DESC)
|
||||||
| GET | `/api/lotto/stats` | 번호 빈도 통계 |
|
|
||||||
| GET | `/api/lotto/analysis` | 5가지 통계 분석 리포트 |
|
|
||||||
| GET | `/api/lotto/best` | 시뮬레이션 최적 번호 (기본 20쌍) |
|
|
||||||
| GET | `/api/lotto/simulation` | 시뮬레이션 상세 결과 |
|
|
||||||
| GET | `/api/lotto/recommend` | 통계 기반 추천 |
|
|
||||||
| GET | `/api/lotto/recommend/heatmap` | 히트맵 기반 추천 |
|
|
||||||
| GET | `/api/lotto/recommend/batch` | 배치 추천 |
|
|
||||||
| POST | `/api/admin/simulate` | 시뮬레이션 수동 실행 |
|
|
||||||
| POST | `/api/admin/sync_latest` | 당첨번호 수동 동기화 |
|
|
||||||
|
|
||||||
#### 추천 이력
|
**스케줄러**
|
||||||
|
- 09:10 / 21:10 — 당첨번호 동기화 + 추천 채점
|
||||||
|
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (후보 20,000 → 상위 100 → best_picks 20쌍 교체)
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
### 2. stock (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
||||||
|--------|------|------|
|
|
||||||
| GET | `/api/history` | 목록 (limit, offset, favorite, tag, sort) |
|
|
||||||
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
|
|
||||||
| DELETE | `/api/history/{id}` | 삭제 |
|
|
||||||
|
|
||||||
#### 투두리스트
|
주식 뉴스 스크래핑 + LLM 요약 + KIS 실계좌 연동 + 포트폴리오·자산 스냅샷.
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
- **뉴스**: 네이버 증권 + 해외 사이트 크롤링, LLM 기반 한국어 요약
|
||||||
|--------|------|------|
|
- **실계좌**: Windows AI 서버(192.168.45.59:8000) 프록시 → KIS Open API (잔고/주문)
|
||||||
| GET | `/api/todos` | 전체 목록 |
|
- **포트폴리오**: 종목·예수금·매도 히스토리 관리, 현재가 자동 조회
|
||||||
| POST | `/api/todos` | 생성 (status: todo\|in_progress\|done) |
|
- **자산 스냅샷**: 평일 15:40 자동 저장 (KRX 공휴일 판별, `holidays.json` 매년 갱신)
|
||||||
| PUT | `/api/todos/{id}` | 수정 |
|
|
||||||
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
|
|
||||||
| DELETE | `/api/todos/{id}` | 개별 삭제 |
|
|
||||||
|
|
||||||
> ⚠️ `/done` 라우트는 반드시 `/{id}` 보다 먼저 등록해야 함
|
**LLM provider 전환** — `LLM_PROVIDER` 환경변수
|
||||||
|
- `claude` (기본): Anthropic Messages API (`claude-haiku-4-5`)
|
||||||
|
- `ollama`: Windows AI 서버 Ollama (`qwen3:14b`)
|
||||||
|
|
||||||
#### 블로그
|
**현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 메모리 캐시
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
### 3. music-lab (`/api/music/`)
|
||||||
|--------|------|------|
|
|
||||||
| GET | `/api/blog/posts` | 글 목록 (`{"posts": [...]}`, date DESC) |
|
|
||||||
| POST | `/api/blog/posts` | 글 생성 (date 미입력 시 오늘 날짜) |
|
|
||||||
| PUT | `/api/blog/posts/{id}` | 글 수정 |
|
|
||||||
| DELETE | `/api/blog/posts/{id}` | 글 삭제 |
|
|
||||||
|
|
||||||
블로그 포스트 구조: `{ id, title, tags[], body, date, excerpt, created_at, updated_at }`
|
듀얼 프로바이더 AI 음악 생성.
|
||||||
|
|
||||||
---
|
- **Suno** (`suno`): REST API 연동, 보컬·가사·인스트루멘탈. 1회 요청 시 2개 variation 생성, 곡 연장, 보컬 분리, WAV 변환, 12스템 분리, 뮤직비디오, AI Cover 등 풀 스위트 지원
|
||||||
|
- **로컬 MusicGen** (`local`): Windows AI PC(RTX 5070 Ti, 16GB VRAM) 인스트루멘탈 전용
|
||||||
|
- **라이브러리**: 생성 파일은 `/app/data/music/`에 저장되고 Nginx가 `/media/music/`으로 직접 서빙
|
||||||
|
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
|
||||||
|
|
||||||
### stock-lab (`/api/stock/`, `/api/trade/`, `/api/portfolio`)
|
### 4. insta-lab (`/api/insta/`)
|
||||||
|
|
||||||
#### 뉴스 & 지표
|
인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드.
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
```
|
||||||
|--------|------|------|
|
NAVER 뉴스 + YouTube 인기 (외부 트렌드)
|
||||||
| GET | `/api/stock/news` | 뉴스 목록 (limit, category) |
|
→ 카테고리별 빈도 + Claude Haiku 정제 → 트렌딩 키워드
|
||||||
| GET | `/api/stock/indices` | 주요 지표 (KOSPI 등) |
|
→ 사용자가 키워드 선택
|
||||||
| POST | `/api/stock/scrap` | 뉴스 수동 스크랩 |
|
→ Claude Sonnet으로 10페이지 카피 추론 (커버 1 + 본문 8 + CTA 1)
|
||||||
|
→ Jinja2 + Playwright 1080×1350 PNG 10장 렌더
|
||||||
|
→ 텔레그램 미디어 그룹 + 추천 캡션·해시태그
|
||||||
|
```
|
||||||
|
|
||||||
#### 실계좌 (Windows AI 서버 프록시)
|
- **AI 엔진**: Claude Sonnet (카피) + Claude Haiku (키워드 분류)
|
||||||
|
- **데이터 소스**: NAVER 뉴스 검색 + YouTube Data API v3 mostPopular(KR)
|
||||||
|
- **카테고리 가중치**: 사용자가 economy/psychology/celebrity 등 카테고리별 가중치 설정 → 자동 추출 비율에 반영
|
||||||
|
- **카드 디자인**: `insta-lab/app/templates/default/card.html.j2` — 사용자가 자유 수정 (Tailwind 등)
|
||||||
|
- **프롬프트 템플릿**: DB에 저장 → 코드 배포 없이 수정 가능
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
### 5. realestate-lab (`/api/realestate/`)
|
||||||
|--------|------|------|
|
|
||||||
| GET | `/api/trade/balance` | 실계좌 잔고 조회 |
|
|
||||||
| POST | `/api/trade/order` | 주문 (BUY\|SELL, price=0이면 시장가) |
|
|
||||||
|
|
||||||
#### 포트폴리오
|
공공데이터포털 청약홈 API 연동 + 프로필 기반 자동 매칭.
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
- **공고 수집**: 09:00 매일 자동 (`DATA_GO_KR_API_KEY` 필요)
|
||||||
|--------|------|------|
|
- **상태 갱신 + 재매칭**: 00:00 매일 자동
|
||||||
| GET | `/api/portfolio` | 전체 조회 (현재가·손익·예수금 포함) |
|
- **프로필 매칭**: 지역·주택형·소득·부양가족 등으로 점수화, 신규 매칭 알림
|
||||||
| POST | `/api/portfolio` | 종목 추가 |
|
- **대시보드**: 진행 중 공고수, 신규 매칭수, 다가오는 일정 요약
|
||||||
| PUT | `/api/portfolio/{id}` | 종목 수정 |
|
|
||||||
| DELETE | `/api/portfolio/{id}` | 종목 삭제 |
|
|
||||||
| GET | `/api/portfolio/cash` | 예수금 전체 조회 |
|
|
||||||
| PUT | `/api/portfolio/cash` | 예수금 upsert |
|
|
||||||
| DELETE | `/api/portfolio/cash/{broker}` | 예수금 삭제 |
|
|
||||||
| POST | `/api/portfolio/snapshot` | 총 자산 스냅샷 수동 저장 |
|
|
||||||
| GET | `/api/portfolio/snapshot/history` | 자산 변화 이력 (days=0: 전체) |
|
|
||||||
|
|
||||||
---
|
### 6. agent-office (`/api/agent-office/`)
|
||||||
|
|
||||||
### travel-proxy (`/api/travel/`)
|
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
- **아키텍처**: stock / music-lab / insta-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||||
|--------|------|------|
|
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
|
||||||
| GET | `/api/travel/regions` | 지역 GeoJSON |
|
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
|
||||||
| GET | `/api/travel/photos` | 사진 목록 (region, page, size) |
|
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||||
| POST | `/api/travel/reload` | 캐시 초기화 |
|
- 봇이 작업 결과를 텔레그램으로 푸시, 명령은 텔레그램에서 바로 에이전트에 전달
|
||||||
|
- Webhook 검증 후 `chat.id` 기준 라우팅
|
||||||
|
|
||||||
- 썸네일: `/media/travel/.thumb/{album}/{file}` (nginx 직접 서빙, 30일 캐시)
|
#### 에이전트 구성
|
||||||
- 원본: `/media/travel/{album}/{file}` (nginx 직접 서빙, 7일 캐시)
|
|
||||||
|
| 에이전트 | 스케줄 | 승인 | 주요 기능 |
|
||||||
|
|---------|--------|-----|----------|
|
||||||
|
| 📈 **주식 트레이더** (`stock`) | 08:00 매일 | — | 뉴스 요약 (LLM) → 텔레그램 아침 브리핑, 종목 알람 등록 |
|
||||||
|
| 🎵 **음악 프로듀서** (`music`) | 수동 트리거 | ✅ 작곡 | 프롬프트 수신 → 승인 → Suno API 작곡 → 트랙 푸시 |
|
||||||
|
| 🎴 **인스타 큐레이터** (`insta`) | 09:00 / 09:30 매일 | — | 09:00 외부 트렌드(NAVER + YouTube) 수집 → 09:30 가중치 기반 키워드 추출 → 텔레그램 후보 5개씩 카테고리당 인라인 버튼 푸시 → 사용자 선택 시 카드 10장 미디어 그룹 |
|
||||||
|
| 🏢 **청약 애널리스트** (`realestate`) | realestate-lab push trigger | — | realestate-lab이 신규 매칭 발견 시 push → 인라인 [북마크] 버튼 포함 텔레그램 알림 |
|
||||||
|
| 🎬 **YouTube 리서처** (`youtube`) | 09:00 매일 | — | 한국 YouTube 트렌딩 + Google Trends + Billboard → music-lab market_trends push |
|
||||||
|
|
||||||
|
#### 에이전트별 명령
|
||||||
|
|
||||||
|
**Stock** — `fetch_news`, `list_alerts`, `add_alert`, `test_telegram`
|
||||||
|
**Music** — `compose` (승인 필요), `credits`
|
||||||
|
**Insta** — `extract`, `render <keyword_id>`, `collect_trends`
|
||||||
|
**Realestate** — `fetch_matches`, `dashboard`
|
||||||
|
**YouTube** — `research {countries: [...]}`
|
||||||
|
|
||||||
|
#### 스케줄러 잡
|
||||||
|
|
||||||
|
- 07:00 월요일 — Lotto: AI 큐레이터 브리핑 (5세트 + 내러티브)
|
||||||
|
- 07:30 — Stock: 뉴스 요약
|
||||||
|
- 08:00 평일 — Stock: AI 뉴스 sentiment 분석
|
||||||
|
- 09:00 — YouTube: 한국 트렌딩 수집
|
||||||
|
- 09:00 — Insta: 외부 트렌드 수집 (NAVER 인기 + YouTube mostPopular)
|
||||||
|
- 09:30 — Insta: 키워드 추출 (가중치 적용) + 텔레그램 후보 푸시
|
||||||
|
- 15:40 평일 — Stock: 총 자산 스냅샷
|
||||||
|
- 16:30 평일 — Stock: 스크리너 실행
|
||||||
|
- 60초 interval — 유휴 에이전트 휴식 체크
|
||||||
|
|
||||||
|
### 7. travel-proxy (`/api/travel/`)
|
||||||
|
|
||||||
|
여행 사진 API + SQLite 인덱스 + 온디맨드 썸네일 + 지역 관리.
|
||||||
|
|
||||||
|
- 원본: `/data/travel/` (RO 마운트)
|
||||||
|
- 썸네일: 480×480 Pillow 리사이징, `/data/thumbs/` 영구 캐시 (tmp → rename 원자성 보장)
|
||||||
|
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||||
|
- 메타: `region_map.json` (RO) + `region_map_extra.json` (RW 오버라이드) + `regions.geojson`
|
||||||
|
- 지역 관리: 앨범 지역 변경, 커스텀 지역 생성, 지도 핀 좌표 지정
|
||||||
|
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
|
||||||
|
|
||||||
|
### 8. deployer (`/webhook`)
|
||||||
|
|
||||||
|
Gitea Webhook 수신 → NAS 자동 배포.
|
||||||
|
|
||||||
|
- HMAC SHA256 서명 검증 (`compare_digest`, `WEBHOOK_SECRET`)
|
||||||
|
- 수신 즉시 200 응답 후 BackgroundTask로 배포
|
||||||
|
- 배포 스크립트: `git pull` → `.releases/` 백업 → `rsync` → `docker compose up -d --build` → `chown PUID:PGID`
|
||||||
|
- 타임아웃 10분
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -213,8 +233,6 @@ curl http://localhost:18500/health
|
|||||||
→ 상위 100개 DB 저장 → best_picks 20개 교체
|
→ 상위 100개 DB 저장 → best_picks 20개 교체
|
||||||
```
|
```
|
||||||
|
|
||||||
**5가지 채점 기법:**
|
|
||||||
|
|
||||||
| 기법 | 가중치 | 내용 |
|
| 기법 | 가중치 | 내용 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| 빈도 Z-score | 25% | 번호 출현 빈도의 표준편차 |
|
| 빈도 Z-score | 25% | 번호 출현 빈도의 표준편차 |
|
||||||
@@ -223,28 +241,21 @@ curl http://localhost:18500/health
|
|||||||
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
||||||
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
||||||
|
|
||||||
**스케줄:** 매일 0, 4, 8, 12, 16, 20시 (하루 6회, 각 5분)
|
### LLM 요약 provider 추상화 (stock)
|
||||||
|
|
||||||
### 총 자산 스냅샷 (stock-lab)
|
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
|
||||||
|
|
||||||
```
|
- `_summarize_with_claude`: Anthropic Messages API 직접 호출 (httpx, SDK 의존성 없음)
|
||||||
평일 15:40 자동 실행 → holidays.json으로 공휴일 스킵
|
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
|
||||||
→ 포트폴리오 현재가 조회 → total_eval
|
- 실패 시 `LLMError` (구 `OllamaError` alias 유지)
|
||||||
→ 예수금 합계 → total_cash
|
|
||||||
→ asset_snapshots upsert (date UNIQUE, 같은 날 중복 시 덮어씀)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 현재가 조회 (stock-lab)
|
### 총 자산 스냅샷 (stock)
|
||||||
|
|
||||||
- 네이버 모바일 API 우선 (`m.stock.naver.com/api/stock/{ticker}/basic`)
|
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
|
||||||
- 실패 시 네이버 금융 HTML 파싱 폴백
|
|
||||||
- 3분 TTL 메모리 캐시
|
|
||||||
|
|
||||||
### 여행 사진 썸네일 (travel-proxy)
|
### 에이전트 FSM + WS 동기화 (agent-office)
|
||||||
|
|
||||||
- 480×480 리사이징 (Pillow), 확장자 유지 (JPEG/PNG/WEBP)
|
DB에 저장된 에이전트 상태가 바뀔 때마다 `websocket_manager`가 전체 클라이언트에 브로드캐스트. 텔레그램 봇은 `waiting` 상태 작업에 인라인 키보드를 붙여 승인 요청. 승인/거부 결과가 DB → WS → 프론트로 전파.
|
||||||
- 온디맨드 생성 후 `/data/thumbs/` 영구 캐시
|
|
||||||
- 원자성 보장: tmp 파일 작성 후 rename
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -252,7 +263,7 @@ curl http://localhost:18500/health
|
|||||||
|
|
||||||
```
|
```
|
||||||
git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
||||||
→ deployer:9000/webhook (서명 검증, compare_digest 사용)
|
→ deployer:9000/webhook (서명 검증, compare_digest)
|
||||||
→ BackgroundTask: scripts/deploy.sh (10분 타임아웃)
|
→ BackgroundTask: scripts/deploy.sh (10분 타임아웃)
|
||||||
1. git pull
|
1. git pull
|
||||||
2. .releases/{timestamp}/ 백업
|
2. .releases/{timestamp}/ 백업
|
||||||
@@ -261,39 +272,32 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
|||||||
5. chown PUID:PGID
|
5. chown PUID:PGID
|
||||||
```
|
```
|
||||||
|
|
||||||
> 프론트엔드는 **자동 배포 안 됨** — 로컬 빌드 후 NAS에 수동 업로드
|
> 프론트엔드는 **자동 배포 안 됨** — 로컬 빌드 후 NAS에 수동 업로드 (`scripts/deploy.bat --frontend`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 데이터베이스
|
## 데이터베이스
|
||||||
|
|
||||||
### lotto.db (`/app/data/lotto.db`)
|
각 서비스는 독립 SQLite DB를 `/app/data/` 볼륨에 저장.
|
||||||
|
|
||||||
| 테이블 | 설명 |
|
| DB | 소유 서비스 | 주요 테이블 |
|
||||||
|--------|------|
|
|----|------------|-----------|
|
||||||
| `draws` | 로또 당첨번호 |
|
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
|
||||||
| `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) |
|
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
||||||
| `simulation_runs` | 시뮬레이션 실행 기록 |
|
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls), video_projects, revenue_records, market_trends, trend_reports |
|
||||||
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
|
| `insta.db` | insta-lab | news_articles, trending_keywords (source 컬럼), card_slates, card_assets, generation_tasks, prompt_templates, account_preferences |
|
||||||
| `best_picks` | 현재 활성 최적 번호 20개 (is_active 플래그) |
|
| `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log |
|
||||||
| `todos` | 투두리스트 (UUID PK) |
|
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages |
|
||||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) |
|
| `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts |
|
||||||
|
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
||||||
### stock.db (`/app/data/stock.db`)
|
| `pack_files` (외부 Supabase) | packs-lab | filename, host_path, mime, byte_size, sha256, deleted_at |
|
||||||
|
|
||||||
| 테이블 | 설명 |
|
|
||||||
|--------|------|
|
|
||||||
| `articles` | 뉴스 기사 (hash UNIQUE, category: domestic\|overseas) |
|
|
||||||
| `portfolio` | 보유 종목 (broker, ticker, quantity, avg_price) |
|
|
||||||
| `broker_cash` | 증권사별 예수금 (broker UNIQUE) |
|
|
||||||
| `asset_snapshots` | 일별 총 자산 스냅샷 (date UNIQUE) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 환경변수
|
## 환경변수
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# 경로 설정
|
# 경로
|
||||||
RUNTIME_PATH=.
|
RUNTIME_PATH=.
|
||||||
REPO_PATH=.
|
REPO_PATH=.
|
||||||
FRONTEND_PATH=./frontend/dist
|
FRONTEND_PATH=./frontend/dist
|
||||||
@@ -306,6 +310,51 @@ PGID=1000
|
|||||||
# 외부 서비스
|
# 외부 서비스
|
||||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||||
WEBHOOK_SECRET=your_secret_here
|
WEBHOOK_SECRET=your_secret_here
|
||||||
|
|
||||||
|
# LLM (stock, insta-lab, agent-office 공통)
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||||
|
LLM_PROVIDER=claude # claude | ollama
|
||||||
|
OLLAMA_URL=http://192.168.45.59:11435
|
||||||
|
OLLAMA_MODEL=qwen3:14b
|
||||||
|
|
||||||
|
# stock admin protection (CODE_REVIEW F2)
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
ALLOW_UNAUTHENTICATED_ADMIN=false
|
||||||
|
|
||||||
|
# music-lab
|
||||||
|
SUNO_API_KEY=
|
||||||
|
MUSIC_AI_SERVER_URL=
|
||||||
|
MUSIC_MEDIA_BASE=/media/music
|
||||||
|
|
||||||
|
# insta-lab + agent-office (NAVER 검색 + YouTube Data API 공유)
|
||||||
|
NAVER_CLIENT_ID=
|
||||||
|
NAVER_CLIENT_SECRET=
|
||||||
|
YOUTUBE_DATA_API_KEY=
|
||||||
|
|
||||||
|
# realestate-lab
|
||||||
|
DATA_GO_KR_API_KEY=
|
||||||
|
|
||||||
|
# packs-lab (DSM + Supabase)
|
||||||
|
DSM_HOST=
|
||||||
|
DSM_USER=
|
||||||
|
DSM_PASS=
|
||||||
|
BACKEND_HMAC_SECRET=
|
||||||
|
SUPABASE_URL=
|
||||||
|
SUPABASE_SERVICE_KEY=
|
||||||
|
PACK_HOST_DIR=/docker/webpage/media/packs # shared folder 시점 (CLAUDE.md F5)
|
||||||
|
|
||||||
|
# agent-office
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_CHAT_ID=
|
||||||
|
TELEGRAM_WEBHOOK_URL=
|
||||||
|
STOCK_URL=http://stock:8000
|
||||||
|
MUSIC_LAB_URL=http://music-lab:8000
|
||||||
|
INSTA_LAB_URL=http://insta-lab:8000
|
||||||
|
REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||||
|
|
||||||
|
# personal (포트폴리오 편집 인증)
|
||||||
|
PORTFOLIO_EDIT_PASSWORD=
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -316,9 +365,9 @@ WEBHOOK_SECRET=your_secret_here
|
|||||||
|------|----|
|
|------|----|
|
||||||
| 장비 | Synology NAS (Intel Celeron J4025, 18GB RAM) |
|
| 장비 | Synology NAS (Intel Celeron J4025, 18GB RAM) |
|
||||||
| Docker | Synology Container Manager |
|
| Docker | Synology Container Manager |
|
||||||
| Git 서버 | Gitea (NAS 내부 self-hosted) |
|
| Git 서버 | Gitea (NAS 내부 self-hosted, `gahusb.synology.me`) |
|
||||||
| AI 서버 | Windows PC (192.168.45.59:8000) — RTX 3070 Ti + Ollama |
|
| AI 서버 | Windows PC (192.168.45.59) — RTX 5070 Ti (16GB VRAM) + Ollama + MusicGen |
|
||||||
| Python | 3.12 (`slim` / `alpine` 기반 이미지) |
|
| Python | 3.12 (`slim` 기반 이미지) |
|
||||||
| DB | SQLite (볼륨 마운트로 영속 저장) |
|
| DB | SQLite (볼륨 마운트로 영속 저장) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -327,8 +376,18 @@ WEBHOOK_SECRET=your_secret_here
|
|||||||
|
|
||||||
- **`.env` 파일** — 절대 커밋 금지. `.env.example`만 레포에 포함
|
- **`.env` 파일** — 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||||
- **Nginx trailing slash** — `/api/portfolio`는 두 location 블록으로 처리 (trailing slash 유무 모두 매칭)
|
- **Nginx trailing slash** — `/api/portfolio`는 두 location 블록으로 처리 (trailing slash 유무 모두 매칭)
|
||||||
- **라우트 순서** — `/api/todos/done`은 `/api/todos/{id}` 보다 먼저 등록 필수
|
- **라우트 순서** — `DELETE /api/todos/done`은 `/api/todos/{id}` 보다 먼저 등록 필수 (FastAPI prefix 매칭)
|
||||||
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
|
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
|
||||||
- **PUID/PGID** — travel-proxy는 NAS 파일 권한을 위해 환경변수 주입 필수
|
- **PUID/PGID** — travel-proxy는 NAS 파일 권한을 위해 환경변수 주입 필수
|
||||||
- **공휴일 목록** — `stock-lab/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
- **공휴일 목록** — `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준)
|
||||||
- **Windows AI 서버** — IP 192.168.45.59 (공유기 DHCP 고정 예약)
|
- **Windows AI 서버 IP** — `192.168.45.59` 공유기 DHCP 고정 예약. Synology Tailscale은 userspace 모드라 TCP 불가 → 로컬 IP 사용
|
||||||
|
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
|
||||||
|
- **LLM provider 롤백** — Claude API 장애 시 `.env`의 `LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`
|
||||||
|
- **시뮬레이션 교체 방식** — `best_picks`는 교체형 (`is_active=0` 비활성화 후 신규 입력)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 문서
|
||||||
|
|
||||||
|
- `CLAUDE.md` — Claude Code 작업용 상세 컨텍스트 (API 전체 목록, 테이블 스키마 등)
|
||||||
|
- `docs/` — 서비스별 기획·설계 문서
|
||||||
|
|||||||
111
STATUS.md
Normal file
111
STATUS.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# web-backend — 구현 현황 & 로드맵
|
||||||
|
|
||||||
|
> 최종 갱신: 2026-05-17
|
||||||
|
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 서비스 구현 현황
|
||||||
|
|
||||||
|
### 1-1. 운영 중인 컨테이너 (11개)
|
||||||
|
|
||||||
|
| 서비스 | 포트 | 상태 | 핵심 기능 |
|
||||||
|
|--------|------|------|-----------|
|
||||||
|
| `lotto` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역·AI 큐레이터 |
|
||||||
|
| `stock` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷·스크리너 |
|
||||||
|
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
|
||||||
|
| `insta-lab` | 18700 | ✅ | 인스타 카드 피드 자동 생성 (NAVER + YouTube 트렌드 → 10페이지 카드, Playwright) |
|
||||||
|
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 push |
|
||||||
|
| `personal` | 18850 | ✅ | 포트폴리오·블로그·투두 통합 (개인 서비스) |
|
||||||
|
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + InstaAgent + YouTubeResearcher) |
|
||||||
|
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase + 5GB chunked upload) |
|
||||||
|
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
|
||||||
|
| `frontend` (nginx) | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit, 인스타 라우팅 포함) |
|
||||||
|
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 (BUILDKIT timeout 600s, healthcheck via docker inspect) |
|
||||||
|
|
||||||
|
### 1-2. 최근 큰 작업 (2026-05)
|
||||||
|
|
||||||
|
| 시기 | 영역 | 핵심 |
|
||||||
|
|------|------|------|
|
||||||
|
| 2026-05-17 | 보안 / 정합성 | CODE_REVIEW F1 (packs-lab path traversal `startswith→relative_to`) + F2 (stock admin auth 503 거부) + F4 (portfolio total_buy 수량 곱산) |
|
||||||
|
| 2026-05-17 | insta-lab | Google Trends API 폐기 대응 → YouTube Data API v3로 source 교체. trend_collector 재작성 |
|
||||||
|
| 2026-05-16 | insta-lab | Trends 탭 추가 — 외부 트렌드 수집 (NAVER 인기 + YouTube) + 카테고리 가중치 (`account_preferences`) + 가중치 기반 키워드 추출 |
|
||||||
|
| 2026-05-15 | insta-lab | blog-lab 폐기 → insta-lab 신설. 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG → 텔레그램 푸시 → 수동 인스타 업로드 파이프라인 |
|
||||||
|
| 2026-05-05 | packs-lab | sign-link / upload / list / delete + admin mint-token + 5GB nginx body limit + Supabase DDL |
|
||||||
|
| 2026-05-01~06 | music-lab | YouTube 수익화 백엔드 (market_trends·trend_reports DB + 5개 API) + 다중 트랙 FFmpeg concat MP4 |
|
||||||
|
| 2026-04-28 | realestate-lab | targeting enhancement (5티어 매칭·5축 점수·알림 대상 카운트, realestate-lab push → agent-office RealestateAgent) |
|
||||||
|
| 2026-04-27 | personal | personal 서비스 분리 마이그레이션 (블로그·투두·포트폴리오 인증) |
|
||||||
|
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
|
||||||
|
| 2026-04-15 | lotto | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
|
||||||
|
|
||||||
|
### 1-3. 인프라 / DX
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| docker-compose 통합 (10 서비스) | ✅ |
|
||||||
|
| Gitea Webhook → deployer rsync 자동 배포 | ✅ |
|
||||||
|
| nginx 라우팅 표 (/api/* 서비스별) | ✅ |
|
||||||
|
| 배포 환경변수 (PEXELS·YOUTUBE_DATA·VIDEO_DATA_DIR 등) | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 진행 중 / 향후 계획
|
||||||
|
|
||||||
|
### 2-1. 로또 프리미엄 (Phase 3) — 구독 모델
|
||||||
|
> 출처: [docs/lotto-premium-roadmap.md](./docs/lotto-premium-roadmap.md)
|
||||||
|
|
||||||
|
- [ ] 회원 시스템 (JWT 인증, `users` 테이블)
|
||||||
|
- [ ] 구독 플랜 (`subscription_plans`, `user_subscriptions`)
|
||||||
|
- [ ] 결제 연동 (Toss Payments 또는 Stripe)
|
||||||
|
- [ ] 이메일 발송 자동화 (SendGrid)
|
||||||
|
- [ ] 소셜 증거 데이터 집계 API (가장 많이 선택된 번호 TOP 10 등)
|
||||||
|
|
||||||
|
Phase 1·2 (성과 통계 / 회차별 공략 리포트 / 개인 분석 / 구매 추적)는 이미 완료.
|
||||||
|
|
||||||
|
### 2-2. Pet Lab (신규 서비스) — 설계 단계
|
||||||
|
> 출처: `docs/superpowers/specs/2026-04-07-pet-lab-design.md`, `plans/2026-04-07-pet-lab.md`
|
||||||
|
|
||||||
|
- [ ] 컨테이너 추가 + 포트 배정
|
||||||
|
- [ ] 핵심 도메인 모델 (반려동물 등록·기록·일정)
|
||||||
|
- [ ] 프론트 페이지 신설
|
||||||
|
|
||||||
|
### 2-3. Music YouTube 자동화 후속
|
||||||
|
|
||||||
|
- [ ] VideoProjects 실제 렌더링 잡 큐 (현재 스켈레톤)
|
||||||
|
- [ ] 시장 트렌드 → 자동 음악 생성 트리거 연결
|
||||||
|
- [ ] Revenue 트래킹 정확도 개선 (YouTube Analytics API)
|
||||||
|
|
||||||
|
### 2-4. Travel 영상 지원
|
||||||
|
|
||||||
|
- [ ] `travel-proxy`에 영상 메타·썸네일 API 추가
|
||||||
|
- [ ] `/media/travel/.video-thumb/` 처리
|
||||||
|
- [ ] `/api/travel/videos` 엔드포인트
|
||||||
|
|
||||||
|
### 2-5. 청약 (realestate-lab) 후속
|
||||||
|
|
||||||
|
- [ ] 알림 dry-run API (사용자가 사전 시뮬레이션 가능)
|
||||||
|
- [ ] 신규 매칭 텔레그램 알림 노이즈 필터링 (이미 본 공고 제외)
|
||||||
|
- [ ] 백오피스용 공고 수동 보정 API
|
||||||
|
|
||||||
|
### 2-6. packs-lab 후속
|
||||||
|
|
||||||
|
- [ ] 사용자별 다운로드 쿼터 제어
|
||||||
|
- [ ] 만료된 토큰/링크 정리 스케줄러
|
||||||
|
- [ ] Vercel SaaS 측 UI 연결 검증
|
||||||
|
|
||||||
|
### 2-7. 인프라 일반
|
||||||
|
|
||||||
|
- [ ] APScheduler 잡 모니터링 대시보드 (현재 로그 의존)
|
||||||
|
- [ ] 백업 자동화 (lotto.db / stock.db / 사진 메타)
|
||||||
|
- [ ] OpenAPI 스펙 통합 (서비스별 자동 수집)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 참고 문서
|
||||||
|
|
||||||
|
- 서비스·포트·API 전체 표: [CLAUDE.md](./CLAUDE.md)
|
||||||
|
- 워크스페이스 통합 가이드: `../CLAUDE.md`
|
||||||
|
- 프론트엔드 상태: `../web-ui/STATUS.md`
|
||||||
|
- 설계 스펙: `docs/superpowers/specs/`
|
||||||
|
- 실행 계획: `docs/superpowers/plans/`
|
||||||
|
- 로또 프리미엄 로드맵: `docs/lotto-premium-roadmap.md`
|
||||||
10
agent-office/Dockerfile
Normal file
10
agent-office/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-alpine
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
1
agent-office/app/__init__.py
Normal file
1
agent-office/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# agent-office/app/__init__.py
|
||||||
27
agent-office/app/agents/__init__.py
Normal file
27
agent-office/app/agents/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from .stock import StockAgent
|
||||||
|
from .music import MusicAgent
|
||||||
|
from .insta import InstaAgent
|
||||||
|
from .realestate import RealestateAgent
|
||||||
|
from .lotto import LottoAgent
|
||||||
|
from .youtube import YouTubeResearchAgent
|
||||||
|
from .youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
AGENT_REGISTRY = {}
|
||||||
|
|
||||||
|
def init_agents():
|
||||||
|
AGENT_REGISTRY["stock"] = StockAgent()
|
||||||
|
AGENT_REGISTRY["music"] = MusicAgent()
|
||||||
|
AGENT_REGISTRY["insta"] = InstaAgent()
|
||||||
|
AGENT_REGISTRY["realestate"] = RealestateAgent()
|
||||||
|
AGENT_REGISTRY["lotto"] = LottoAgent()
|
||||||
|
AGENT_REGISTRY["youtube"] = YouTubeResearchAgent()
|
||||||
|
AGENT_REGISTRY["youtube_publisher"] = YoutubePublisherAgent()
|
||||||
|
|
||||||
|
def get_agent(agent_id: str):
|
||||||
|
return AGENT_REGISTRY.get(agent_id)
|
||||||
|
|
||||||
|
def get_all_agent_states() -> list:
|
||||||
|
return [
|
||||||
|
{"agent_id": aid, "state": agent.state, "detail": agent.state_detail}
|
||||||
|
for aid, agent in AGENT_REGISTRY.items()
|
||||||
|
]
|
||||||
60
agent-office/app/agents/base.py
Normal file
60
agent-office/app/agents/base.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..db import add_log
|
||||||
|
|
||||||
|
VALID_STATES = ("idle", "working", "waiting", "reporting")
|
||||||
|
|
||||||
|
class BaseAgent:
|
||||||
|
agent_id: str = ""
|
||||||
|
display_name: str = ""
|
||||||
|
state: str = "idle"
|
||||||
|
state_detail: str = ""
|
||||||
|
_idle_since: float = 0.0
|
||||||
|
_ws_manager = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._idle_since = time.time()
|
||||||
|
|
||||||
|
def set_ws_manager(self, manager):
|
||||||
|
self._ws_manager = manager
|
||||||
|
|
||||||
|
async def transition(self, new_state: str, detail: str = "", task_id: str = None) -> None:
|
||||||
|
if new_state not in VALID_STATES:
|
||||||
|
return
|
||||||
|
old = self.state
|
||||||
|
self.state = new_state
|
||||||
|
self.state_detail = detail
|
||||||
|
|
||||||
|
if new_state == "idle":
|
||||||
|
self._idle_since = time.time()
|
||||||
|
|
||||||
|
add_log(self.agent_id, f"State: {old} -> {new_state} ({detail})")
|
||||||
|
|
||||||
|
if self._ws_manager:
|
||||||
|
await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id)
|
||||||
|
if new_state == "working" and old != "working":
|
||||||
|
await self._ws_manager.send_notification(
|
||||||
|
self.agent_id, "task_assigned", task_id, detail or "새 작업 시작"
|
||||||
|
)
|
||||||
|
elif new_state == "idle" and old in ("working", "reporting"):
|
||||||
|
await self._ws_manager.send_notification(
|
||||||
|
self.agent_id, "task_completed", task_id, detail or "작업 완료"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_status(self) -> dict:
|
||||||
|
return {
|
||||||
|
"agent_id": self.agent_id,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"state": self.state,
|
||||||
|
"detail": self.state_detail,
|
||||||
|
}
|
||||||
75
agent-office/app/agents/classify_intent.py
Normal file
75
agent-office/app/agents/classify_intent.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""텔레그램 사용자 응답 자연어 분류 — 화이트리스트 우선, 모호 시 LLM."""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-office.classify_intent")
|
||||||
|
|
||||||
|
CLAUDE_HAIKU_DEFAULT = "claude-haiku-4-5-20251001"
|
||||||
|
|
||||||
|
APPROVE_WORDS = {
|
||||||
|
"승인", "시작", "진행", "ok", "okay", "agree",
|
||||||
|
"네", "예", "좋아", "좋아요", "go", "yes", "y",
|
||||||
|
}
|
||||||
|
REJECT_WORDS = {"반려", "거절", "취소", "no", "nope", "n"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_key() -> str:
|
||||||
|
return os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_model() -> str:
|
||||||
|
return os.getenv("CLAUDE_HAIKU_MODEL", CLAUDE_HAIKU_DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(text: str) -> tuple[str, str | None]:
|
||||||
|
"""returns (intent, feedback) — intent ∈ {approve, reject, unclear}"""
|
||||||
|
if not text:
|
||||||
|
return ("unclear", None)
|
||||||
|
t = text.strip().lower()
|
||||||
|
if t in APPROVE_WORDS:
|
||||||
|
return ("approve", None)
|
||||||
|
if t in REJECT_WORDS:
|
||||||
|
return ("reject", None)
|
||||||
|
# 반려 단어로 시작 + 추가 텍스트
|
||||||
|
for w in REJECT_WORDS:
|
||||||
|
if t.startswith(w):
|
||||||
|
rest = text.strip()[len(w):].lstrip(" ,.-:").strip()
|
||||||
|
if rest:
|
||||||
|
return ("reject", rest)
|
||||||
|
# 승인 단어로 시작 (긍정 의도면 추가 텍스트 무시)
|
||||||
|
for w in APPROVE_WORDS:
|
||||||
|
if t.startswith(w + " ") or t == w:
|
||||||
|
return ("approve", None)
|
||||||
|
return _llm_classify(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_classify(text: str) -> tuple[str, str | None]:
|
||||||
|
api_key = _get_api_key()
|
||||||
|
if not api_key:
|
||||||
|
return ("unclear", None)
|
||||||
|
prompt = (
|
||||||
|
"사용자 응답을 분류하세요. JSON으로만 응답.\n"
|
||||||
|
f'응답: "{text}"\n\n'
|
||||||
|
'출력: {"intent":"approve|reject|unclear","feedback":"반려면 수정 방향, 아니면 빈 문자열"}'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = httpx.post(
|
||||||
|
"https://api.anthropic.com/v1/messages",
|
||||||
|
headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"},
|
||||||
|
json={"model": _get_model(), "max_tokens": 200,
|
||||||
|
"messages": [{"role": "user", "content": prompt}]},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
text_out = resp.json()["content"][0]["text"]
|
||||||
|
start = text_out.find("{")
|
||||||
|
end = text_out.rfind("}") + 1
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
return ("unclear", None)
|
||||||
|
data = json.loads(text_out[start:end])
|
||||||
|
return (data.get("intent", "unclear"), data.get("feedback") or None)
|
||||||
|
except (httpx.HTTPError, httpx.TimeoutException, KeyError, ValueError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning("LLM 분류 실패: %s", e)
|
||||||
|
return ("unclear", None)
|
||||||
194
agent-office/app/agents/insta.py
Normal file
194
agent-office/app/agents/insta.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""인스타 카드 에이전트 — 매일 09:30 뉴스 수집·키워드 추출 → 텔레그램 후보 푸시.
|
||||||
|
사용자가 키워드 버튼을 누르면 카드 슬레이트 생성 + 10장 미디어 그룹 발송."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import BaseAgent
|
||||||
|
from ..db import (
|
||||||
|
create_task, update_task_status, add_log, get_agent_config,
|
||||||
|
)
|
||||||
|
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
|
||||||
|
from .. import service_proxy
|
||||||
|
from ..telegram import messaging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 텔레그램 후보 푸시 시 "확실한 것만" 보내기 위한 최소 신뢰도 (키워드 score 0~1)
|
||||||
|
KEYWORD_MIN_SCORE = 0.7
|
||||||
|
|
||||||
|
|
||||||
|
def _dedup_and_filter_keywords(
|
||||||
|
keywords: List[Dict[str, Any]], min_score: float = KEYWORD_MIN_SCORE,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""score >= min_score 인 키워드만 남기고, 동일 keyword 중복 제거(최고 score 유지).
|
||||||
|
결과는 score 내림차순. 텔레그램 후보 푸시 전 정리용."""
|
||||||
|
best: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for k in keywords:
|
||||||
|
if float(k.get("score", 0)) < min_score:
|
||||||
|
continue
|
||||||
|
name = str(k.get("keyword", "")).strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if name not in best or k["score"] > best[name]["score"]:
|
||||||
|
best[name] = k
|
||||||
|
return sorted(best.values(), key=lambda k: -k["score"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_media_group(media: List[Dict[str, Any]], caption: str = "") -> Dict[str, Any]:
|
||||||
|
"""텔레그램 sendMediaGroup. media는 InputMediaPhoto dicts.
|
||||||
|
각 항목에는 임시 키 '_bytes'로 PNG 바이트가 담겨 있어 attach:// 형식으로 multipart 업로드."""
|
||||||
|
if not TELEGRAM_BOT_TOKEN:
|
||||||
|
return {"ok": False, "reason": "TELEGRAM_BOT_TOKEN missing"}
|
||||||
|
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMediaGroup"
|
||||||
|
files: Dict[str, tuple] = {}
|
||||||
|
for i, m in enumerate(media):
|
||||||
|
attach_key = f"photo{i+1}"
|
||||||
|
files[attach_key] = (f"{i+1}.png", m["_bytes"], "image/png")
|
||||||
|
m["media"] = f"attach://{attach_key}"
|
||||||
|
m.pop("_bytes", None)
|
||||||
|
if caption and media:
|
||||||
|
media[0]["caption"] = caption[:1024]
|
||||||
|
payload = {"chat_id": TELEGRAM_CHAT_ID, "media": json.dumps(media, ensure_ascii=False)}
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
resp = await client.post(url, data=payload, files=files)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
class InstaAgent(BaseAgent):
|
||||||
|
agent_id = "insta"
|
||||||
|
display_name = "인스타 큐레이터"
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
"""09:30 매일: 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시.
|
||||||
|
custom_config.auto_select=True면 카테고리당 1위 키워드 자동 슬레이트 생성."""
|
||||||
|
if self.state != "idle":
|
||||||
|
return
|
||||||
|
config = get_agent_config(self.agent_id) or {}
|
||||||
|
custom = config.get("custom_config", {}) or {}
|
||||||
|
auto_select = bool(custom.get("auto_select", False))
|
||||||
|
|
||||||
|
task_id = create_task(self.agent_id, "insta_daily", {"auto_select": auto_select},
|
||||||
|
requires_approval=False)
|
||||||
|
await self.transition("working", "뉴스 수집·키워드 추출", task_id)
|
||||||
|
try:
|
||||||
|
prefs = await service_proxy.insta_get_preferences()
|
||||||
|
add_log(self.agent_id, f"insta preferences: {prefs}", "info", task_id)
|
||||||
|
await self._run_collect_and_extract()
|
||||||
|
kws = await service_proxy.insta_list_keywords(used=False)
|
||||||
|
if auto_select:
|
||||||
|
await self._auto_render(kws)
|
||||||
|
else:
|
||||||
|
await self._push_keyword_candidates(kws)
|
||||||
|
update_task_status(task_id, "succeeded", {"keywords": len(kws)})
|
||||||
|
await self.transition("idle", "후보 푸시 완료")
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"insta daily failed: {e}", "error", task_id)
|
||||||
|
update_task_status(task_id, "failed", {"error": str(e)})
|
||||||
|
await self.transition("idle", f"오류: {e}")
|
||||||
|
|
||||||
|
async def _run_collect_and_extract(self) -> None:
|
||||||
|
col = await service_proxy.insta_collect()
|
||||||
|
await self._wait_task(col["task_id"], step="collect", timeout_sec=300)
|
||||||
|
ext = await service_proxy.insta_extract()
|
||||||
|
await self._wait_task(ext["task_id"], step="extract", timeout_sec=300)
|
||||||
|
|
||||||
|
async def _wait_task(self, task_id: str, step: str, timeout_sec: int = 300) -> Dict[str, Any]:
|
||||||
|
attempts = max(1, timeout_sec // 5)
|
||||||
|
for _ in range(attempts):
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
st = await service_proxy.insta_task_status(task_id)
|
||||||
|
if st["status"] == "succeeded":
|
||||||
|
return st
|
||||||
|
if st["status"] == "failed":
|
||||||
|
raise RuntimeError(f"{step} failed: {st.get('error')}")
|
||||||
|
raise TimeoutError(f"{step} timeout {timeout_sec}s")
|
||||||
|
|
||||||
|
async def _push_keyword_candidates(self, keywords: List[Dict[str, Any]]) -> None:
|
||||||
|
# 중복 제거 + 신뢰도(score) 임계값 이상만 — "확실한 것만" 정리해서 전송
|
||||||
|
filtered = _dedup_and_filter_keywords(keywords)
|
||||||
|
if not filtered:
|
||||||
|
await messaging.send_raw(
|
||||||
|
f"📰 [인스타 큐레이터] 오늘은 확실한 추천 키워드가 없습니다 (신뢰도 {KEYWORD_MIN_SCORE:.1f}+ 기준)."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
by_cat: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
for k in filtered:
|
||||||
|
by_cat.setdefault(k["category"], []).append(k)
|
||||||
|
rows: List[List[Dict[str, Any]]] = []
|
||||||
|
text_lines = [f"📰 <b>[인스타 큐레이터]</b> 오늘의 키워드 후보 (신뢰도 {KEYWORD_MIN_SCORE:.1f}+)"]
|
||||||
|
for cat, items in by_cat.items():
|
||||||
|
text_lines.append(f"\n<b>{cat}</b>")
|
||||||
|
for k in items[:5]:
|
||||||
|
text_lines.append(f" · {k['keyword']} (score {k['score']:.2f})")
|
||||||
|
rows.append([{
|
||||||
|
"text": f"🎴 {k['keyword']}",
|
||||||
|
"callback_data": f"render_{k['id']}",
|
||||||
|
}])
|
||||||
|
await messaging.send_raw("\n".join(text_lines), reply_markup={"inline_keyboard": rows})
|
||||||
|
|
||||||
|
async def _auto_render(self, keywords: List[Dict[str, Any]]) -> None:
|
||||||
|
by_cat: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for k in keywords:
|
||||||
|
cat = k["category"]
|
||||||
|
if cat not in by_cat or k["score"] > by_cat[cat]["score"]:
|
||||||
|
by_cat[cat] = k
|
||||||
|
for kw in by_cat.values():
|
||||||
|
await self._render_and_push(kw["id"])
|
||||||
|
|
||||||
|
async def _render_and_push(self, keyword_id: int) -> None:
|
||||||
|
kw = await service_proxy.insta_get_keyword(keyword_id)
|
||||||
|
if not kw:
|
||||||
|
await messaging.send_raw(f"⚠️ 키워드 {keyword_id} 없음")
|
||||||
|
return
|
||||||
|
await messaging.send_raw(f"🎨 카드 생성 중: <b>{kw['keyword']}</b>")
|
||||||
|
created = await service_proxy.insta_create_slate(
|
||||||
|
keyword=kw["keyword"], category=kw["category"], keyword_id=kw["id"],
|
||||||
|
)
|
||||||
|
st = await self._wait_task(created["task_id"], step="slate", timeout_sec=600)
|
||||||
|
slate_id = st["result_id"]
|
||||||
|
slate = await service_proxy.insta_get_slate(slate_id)
|
||||||
|
media = []
|
||||||
|
for a in slate["assets"][:10]:
|
||||||
|
data = await service_proxy.insta_get_asset_bytes(slate_id, a["page_index"])
|
||||||
|
media.append({"type": "photo", "_bytes": data})
|
||||||
|
caption = slate.get("suggested_caption", "")
|
||||||
|
hashtags = " ".join(slate.get("hashtags", []) or [])
|
||||||
|
full_caption = f"{caption}\n\n{hashtags}".strip()
|
||||||
|
await _send_media_group(media, caption=full_caption)
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
if command == "extract":
|
||||||
|
await self._run_collect_and_extract()
|
||||||
|
kws = await service_proxy.insta_list_keywords(used=False)
|
||||||
|
await self._push_keyword_candidates(kws)
|
||||||
|
return {"ok": True, "count": len(kws)}
|
||||||
|
if command == "render":
|
||||||
|
kid = int(params.get("keyword_id") or 0)
|
||||||
|
if not kid:
|
||||||
|
return {"ok": False, "message": "keyword_id 필수"}
|
||||||
|
await self._render_and_push(kid)
|
||||||
|
return {"ok": True}
|
||||||
|
if command == "collect_trends":
|
||||||
|
await messaging.send_raw("🌐 외부 트렌드 수집 시작")
|
||||||
|
created = await service_proxy.insta_collect_trends()
|
||||||
|
st = await self._wait_task(created["task_id"], step="trends_collect", timeout_sec=300)
|
||||||
|
await messaging.send_raw(f"✅ 트렌드 수집 완료: {st.get('message', '')}")
|
||||||
|
return {"ok": True, "result": st}
|
||||||
|
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||||
|
|
||||||
|
async def on_callback(self, action: str, params: dict) -> dict:
|
||||||
|
if action == "render":
|
||||||
|
kid = int(params.get("keyword_id") or 0)
|
||||||
|
if not kid:
|
||||||
|
return {"ok": False}
|
||||||
|
await self._render_and_push(kid)
|
||||||
|
return {"ok": True}
|
||||||
|
return {"ok": False}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
return
|
||||||
267
agent-office/app/agents/lotto.py
Normal file
267
agent-office/app/agents/lotto.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
from .base import BaseAgent
|
||||||
|
from ..db import create_task, update_task_status, add_log
|
||||||
|
from ..curator.pipeline import curate_weekly, CuratorError
|
||||||
|
|
||||||
|
|
||||||
|
class LottoAgent(BaseAgent):
|
||||||
|
agent_id = "lotto"
|
||||||
|
display_name = "로또 큐레이터"
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
if self.state != "idle":
|
||||||
|
return
|
||||||
|
await self._run(source="auto")
|
||||||
|
|
||||||
|
async def on_command(self, action: str, params: dict) -> dict:
|
||||||
|
if action in ("curate_now", "curate_weekly"):
|
||||||
|
return await self._run(source="manual")
|
||||||
|
if action == "status":
|
||||||
|
return {"ok": True, "message": f"{self.state}: {self.state_detail}"}
|
||||||
|
if action in ("signal_check", "light_check", "sim_check", "deep_check"):
|
||||||
|
source = action.replace("_check", "") if action != "signal_check" else "light"
|
||||||
|
return await self.run_signal_check(source=source)
|
||||||
|
if action == "daily_digest":
|
||||||
|
return await self.run_daily_digest()
|
||||||
|
return {"ok": False, "message": f"unknown action: {action}"}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def run_signal_check(self, source: str = "light") -> dict:
|
||||||
|
"""비-LLM 시그널 평가. task_id wrap 적용."""
|
||||||
|
from ..curator.signal_runner import run_signal_check
|
||||||
|
from ..config import (
|
||||||
|
LOTTO_Z_NORMAL, LOTTO_Z_URGENT,
|
||||||
|
LOTTO_THROTTLE_HOURS, LOTTO_URGENT_DAILY_MAX,
|
||||||
|
)
|
||||||
|
from ..db import (
|
||||||
|
create_task, update_task_status, add_log,
|
||||||
|
get_last_signal_notification, get_recent_urgent_count,
|
||||||
|
mark_signal_notified,
|
||||||
|
)
|
||||||
|
from ..notifiers.telegram_lotto import send_urgent_signal
|
||||||
|
from ..service_proxy import lotto_latest_draw
|
||||||
|
|
||||||
|
if self.state not in ("idle", "reporting"):
|
||||||
|
return {"ok": False, "message": f"busy ({self.state})"}
|
||||||
|
|
||||||
|
task_id = create_task("lotto", "signal_check", {"source": source})
|
||||||
|
try:
|
||||||
|
curate_result = None
|
||||||
|
current_draw_no = await lotto_latest_draw()
|
||||||
|
|
||||||
|
if source == "deep":
|
||||||
|
from ..curator.pipeline import curate_weekly
|
||||||
|
cw = await curate_weekly(source="signal_deep")
|
||||||
|
curate_result = {"confidence": cw.get("confidence")}
|
||||||
|
if cw.get("draw_no"):
|
||||||
|
current_draw_no = cw.get("draw_no")
|
||||||
|
|
||||||
|
outcome = await run_signal_check(
|
||||||
|
source=source,
|
||||||
|
z_normal=LOTTO_Z_NORMAL,
|
||||||
|
z_urgent=LOTTO_Z_URGENT,
|
||||||
|
curate_result=curate_result,
|
||||||
|
current_draw_no=current_draw_no,
|
||||||
|
)
|
||||||
|
|
||||||
|
# urgent 텔레그램 + throttle (기존 동작 유지)
|
||||||
|
if outcome["overall_fire"] == "urgent":
|
||||||
|
if get_recent_urgent_count(hours=24) >= LOTTO_URGENT_DAILY_MAX:
|
||||||
|
add_log("lotto", "urgent daily cap 도달 → normal로 강등", level="warning", task_id=task_id)
|
||||||
|
else:
|
||||||
|
blocked = False
|
||||||
|
for r in outcome["results"]:
|
||||||
|
if r["fire_level"] in ("normal", "urgent"):
|
||||||
|
if get_last_signal_notification(
|
||||||
|
metric=r["metric"], fire_level=r["fire_level"],
|
||||||
|
hours=LOTTO_THROTTLE_HOURS,
|
||||||
|
):
|
||||||
|
blocked = True
|
||||||
|
break
|
||||||
|
if not blocked:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
event = {
|
||||||
|
"fire_level": "urgent",
|
||||||
|
"triggered_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"results": outcome["results"],
|
||||||
|
}
|
||||||
|
await send_urgent_signal(event)
|
||||||
|
for r in outcome["results"]:
|
||||||
|
if r["fire_level"] in ("normal", "urgent"):
|
||||||
|
mark_signal_notified(r["signal_id"])
|
||||||
|
add_log("lotto", f"urgent 텔레그램 발송 ({len(outcome['results'])}개 시그널)", task_id=task_id)
|
||||||
|
|
||||||
|
fired_metrics = [
|
||||||
|
r["metric"] for r in outcome["results"]
|
||||||
|
if r["fire_level"] not in ("noop", "warmup")
|
||||||
|
]
|
||||||
|
update_task_status(task_id, "succeeded", result_data={
|
||||||
|
"source": source,
|
||||||
|
"overall_fire": outcome["overall_fire"],
|
||||||
|
"n_results": len(outcome["results"]),
|
||||||
|
"fired_metrics": fired_metrics,
|
||||||
|
})
|
||||||
|
add_log("lotto", f"signal_check({source}) → {outcome['overall_fire']} results={len(outcome['results'])}", task_id=task_id)
|
||||||
|
return {"ok": True, **outcome}
|
||||||
|
except Exception as e:
|
||||||
|
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||||
|
add_log("lotto", f"signal_check 예외: {e}", level="error", task_id=task_id)
|
||||||
|
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||||
|
|
||||||
|
async def run_daily_digest(self) -> dict:
|
||||||
|
"""일일 요약 — 지난 24h normal/urgent 발화 텔레그램 1통. task_id wrap."""
|
||||||
|
from ..db import (
|
||||||
|
create_task, update_task_status, add_log,
|
||||||
|
get_recent_lotto_signals, get_signals_history, get_baseline,
|
||||||
|
)
|
||||||
|
from ..notifiers.telegram_lotto import send_signal_summary
|
||||||
|
|
||||||
|
task_id = create_task("lotto", "daily_digest", {})
|
||||||
|
try:
|
||||||
|
sigs = get_recent_lotto_signals(hours=24, min_fire="normal")
|
||||||
|
total_24h = get_signals_history(days=1)
|
||||||
|
evaluated = len(total_24h)
|
||||||
|
|
||||||
|
trend = {}
|
||||||
|
try:
|
||||||
|
cache = get_baseline("drift_weights_cache")
|
||||||
|
if cache and isinstance(cache["window_values"], list) and len(cache["window_values"]) >= 2:
|
||||||
|
prev_w = cache["window_values"][-2]
|
||||||
|
curr_w = cache["window_values"][-1]
|
||||||
|
trend = {
|
||||||
|
k: curr_w.get(k, 0.0) - prev_w.get(k, 0.0)
|
||||||
|
for k in (set(prev_w) | set(curr_w))
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
add_log("lotto", f"weights_trend 계산 실패: {e}", level="warning", task_id=task_id)
|
||||||
|
|
||||||
|
digest = {
|
||||||
|
"evaluated": evaluated,
|
||||||
|
"fired": len(sigs),
|
||||||
|
"signals": sigs,
|
||||||
|
"weights_trend": trend,
|
||||||
|
}
|
||||||
|
await send_signal_summary(digest)
|
||||||
|
update_task_status(task_id, "succeeded", result_data={
|
||||||
|
"evaluated": evaluated,
|
||||||
|
"fired": len(sigs),
|
||||||
|
"signals_count": len(sigs),
|
||||||
|
})
|
||||||
|
add_log("lotto", f"daily_digest 발송: 평가 {evaluated} / 발화 {len(sigs)}", task_id=task_id)
|
||||||
|
return {"ok": True, **digest}
|
||||||
|
except Exception as e:
|
||||||
|
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||||
|
add_log("lotto", f"daily_digest 예외: {e}", level="error", task_id=task_id)
|
||||||
|
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||||
|
|
||||||
|
async def run_weekly_evolution_report(self) -> dict:
|
||||||
|
"""토 22:15 — lotto-lab evaluate-now 트리거 후 텔레그램 리포트. task_id wrap."""
|
||||||
|
from ..service_proxy import lotto_evolver_evaluate, lotto_evolver_status
|
||||||
|
from ..notifiers.telegram_lotto import send_evolution_report
|
||||||
|
from ..db import create_task, update_task_status, add_log
|
||||||
|
|
||||||
|
task_id = create_task("lotto", "weekly_evolution_report", {})
|
||||||
|
try:
|
||||||
|
eval_result = await lotto_evolver_evaluate()
|
||||||
|
status = await lotto_evolver_status()
|
||||||
|
current_base = status.get("current_base") or [0.2] * 5
|
||||||
|
await send_evolution_report(eval_result, current_base)
|
||||||
|
|
||||||
|
winner = eval_result.get("winner") or {}
|
||||||
|
update_task_status(task_id, "succeeded", result_data={
|
||||||
|
"draw_no": eval_result.get("draw_no"),
|
||||||
|
"update_reason": eval_result.get("update_reason"),
|
||||||
|
"winner_day_of_week": winner.get("day_of_week"),
|
||||||
|
"winner_max_correct": winner.get("max_correct"),
|
||||||
|
})
|
||||||
|
add_log("lotto", f"weekly_evolution_report 발송: draw={eval_result.get('draw_no')} reason={eval_result.get('update_reason')}", task_id=task_id)
|
||||||
|
return {"ok": True, **eval_result}
|
||||||
|
except Exception as e:
|
||||||
|
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||||
|
add_log("lotto", f"weekly_evolution_report 예외: {e}", level="error", task_id=task_id)
|
||||||
|
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||||
|
|
||||||
|
async def sync_evolver_activity(self) -> dict:
|
||||||
|
"""매일 09:30 — lotto-lab evolver 상태 polling → agent_office.db에 task+log 거울. 멱등."""
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from ..service_proxy import lotto_evolver_status
|
||||||
|
from ..db import (
|
||||||
|
create_task, update_task_status, add_log,
|
||||||
|
get_tasks_by_agent_date_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
KST = timezone(timedelta(hours=9))
|
||||||
|
today_kst = datetime.now(KST).date()
|
||||||
|
# created_at은 UTC로 저장되므로 idempotency guard는 UTC 날짜 기준
|
||||||
|
today_utc_iso = datetime.now(timezone.utc).date().isoformat()
|
||||||
|
dow = today_kst.weekday()
|
||||||
|
if dow == 6:
|
||||||
|
dow = 5
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = await lotto_evolver_status()
|
||||||
|
except Exception as e:
|
||||||
|
add_log("lotto", f"sync_evolver_activity: lotto-lab status fetch 실패: {e}", level="warning")
|
||||||
|
return {"ok": False, "reason": "status_fetch_failed", "error": str(e)}
|
||||||
|
|
||||||
|
results = {"created": []}
|
||||||
|
|
||||||
|
today_trial = next((t for t in status.get("trials", []) if t.get("day_of_week") == dow), None)
|
||||||
|
if today_trial and today_trial.get("picks"):
|
||||||
|
if not get_tasks_by_agent_date_kind("lotto", today_utc_iso, "evolver_apply"):
|
||||||
|
tid = create_task("lotto", "evolver_apply", {
|
||||||
|
"date": today_utc_iso,
|
||||||
|
"trial_id": today_trial["id"],
|
||||||
|
"day_of_week": dow,
|
||||||
|
"weight": today_trial["weight"],
|
||||||
|
})
|
||||||
|
update_task_status(tid, "succeeded", result_data={
|
||||||
|
"n_picks": len(today_trial["picks"]),
|
||||||
|
"meta_scores": [p.get("meta_score") for p in today_trial["picks"]],
|
||||||
|
})
|
||||||
|
add_log("lotto", f"evolver_apply: 오늘({dow}) W로 {len(today_trial['picks'])}세트 추출", task_id=tid)
|
||||||
|
results["created"].append("evolver_apply")
|
||||||
|
|
||||||
|
if today_kst.weekday() == 0 and len(status.get("trials", [])) == 6:
|
||||||
|
if not get_tasks_by_agent_date_kind("lotto", today_utc_iso, "evolver_generate"):
|
||||||
|
tid = create_task("lotto", "evolver_generate", {"week_start": status.get("week_start")})
|
||||||
|
update_task_status(tid, "succeeded", result_data={
|
||||||
|
"trials_count": 6,
|
||||||
|
"candidates_per_source": {"perturb": 4, "dirichlet": 2},
|
||||||
|
})
|
||||||
|
add_log("lotto", f"evolver_generate: {status.get('week_start')} 주의 6 trials 생성", task_id=tid)
|
||||||
|
results["created"].append("evolver_generate")
|
||||||
|
|
||||||
|
return {"ok": True, **results}
|
||||||
|
|
||||||
|
async def _run(self, source: str) -> dict:
|
||||||
|
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
|
||||||
|
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)
|
||||||
|
try:
|
||||||
|
result = await curate_weekly(source=source)
|
||||||
|
update_task_status(task_id, "succeeded", result_data={
|
||||||
|
k: v for k, v in result.items() if k != "payload"
|
||||||
|
})
|
||||||
|
await self.transition("reporting", f"#{result['draw_no']} 브리핑 저장 완료")
|
||||||
|
add_log(self.agent_id, f"큐레이션 완료: #{result['draw_no']} conf={result['confidence']}", task_id=task_id)
|
||||||
|
|
||||||
|
# 텔레그램 헤드라인 푸시 (실패해도 큐레이션은 성공으로 마감)
|
||||||
|
try:
|
||||||
|
from ..notifiers.telegram_lotto import send_curator_briefing
|
||||||
|
await send_curator_briefing(result["payload"])
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"텔레그램 알림 실패: {e}", level="warning", task_id=task_id)
|
||||||
|
|
||||||
|
await self.transition("idle", "대기 중")
|
||||||
|
return {"ok": True, **{k: v for k, v in result.items() if k != "payload"}}
|
||||||
|
except CuratorError as e:
|
||||||
|
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||||
|
add_log(self.agent_id, f"큐레이션 실패: {e}", level="error", task_id=task_id)
|
||||||
|
await self.transition("idle", "오류")
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
update_task_status(task_id, "failed", result_data={"error": str(e)})
|
||||||
|
add_log(self.agent_id, f"큐레이션 예외: {e}", level="error", task_id=task_id)
|
||||||
|
await self.transition("idle", "오류")
|
||||||
|
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
|
||||||
124
agent-office/app/agents/music.py
Normal file
124
agent-office/app/agents/music.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import asyncio
|
||||||
|
from .base import BaseAgent
|
||||||
|
from ..db import create_task, update_task_status, approve_task, reject_task, add_log
|
||||||
|
from .. import service_proxy
|
||||||
|
from .. import telegram_bot
|
||||||
|
|
||||||
|
class MusicAgent(BaseAgent):
|
||||||
|
agent_id = "music"
|
||||||
|
display_name = "음악 프로듀서"
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
if command == "compose":
|
||||||
|
prompt = params.get("prompt", "")
|
||||||
|
style = params.get("style", "")
|
||||||
|
model = params.get("model", "V4")
|
||||||
|
instrumental = params.get("instrumental", False)
|
||||||
|
|
||||||
|
if not prompt:
|
||||||
|
return {"ok": False, "message": "프롬프트를 입력해주세요"}
|
||||||
|
|
||||||
|
task_id = create_task(self.agent_id, "compose", {
|
||||||
|
"prompt": prompt, "style": style,
|
||||||
|
"model": model, "instrumental": instrumental,
|
||||||
|
}, requires_approval=True)
|
||||||
|
|
||||||
|
await self.transition("waiting", "프롬프트 승인 대기", task_id)
|
||||||
|
|
||||||
|
detail = f"프롬프트: {prompt}"
|
||||||
|
if style:
|
||||||
|
detail += f"\n스타일: {style}"
|
||||||
|
detail += f"\n모델: {model}"
|
||||||
|
|
||||||
|
await telegram_bot.send_approval_request(
|
||||||
|
self.agent_id, task_id,
|
||||||
|
"🎵 [음악 에이전트] 작곡 요청", detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"ok": True, "task_id": task_id, "message": "승인 대기 중"}
|
||||||
|
|
||||||
|
if command == "credits":
|
||||||
|
credits = await service_proxy.get_music_credits()
|
||||||
|
return {"ok": True, "credits": credits}
|
||||||
|
|
||||||
|
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
if not approved:
|
||||||
|
reject_task(task_id)
|
||||||
|
await self.transition("idle", "작곡 거절됨")
|
||||||
|
await telegram_bot.send_task_result(
|
||||||
|
self.agent_id, "🎵 [음악 에이전트] 작곡 취소",
|
||||||
|
"사용자가 거절했습니다.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
from ..db import get_task
|
||||||
|
task = get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
return
|
||||||
|
|
||||||
|
approve_task(task_id, via="telegram")
|
||||||
|
await self.transition("working", "작곡 중...", task_id)
|
||||||
|
asyncio.create_task(self._poll_composition(task_id, task))
|
||||||
|
|
||||||
|
async def _poll_composition(self, task_id: str, task: dict) -> None:
|
||||||
|
try:
|
||||||
|
input_data = task["input_data"]
|
||||||
|
payload = {
|
||||||
|
"provider": "suno",
|
||||||
|
"model": input_data.get("model", "V4"),
|
||||||
|
"prompt": input_data.get("prompt", ""),
|
||||||
|
"style": input_data.get("style", ""),
|
||||||
|
"instrumental": input_data.get("instrumental", False),
|
||||||
|
"custom_mode": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await service_proxy.generate_music(payload)
|
||||||
|
music_task_id = result.get("task_id")
|
||||||
|
|
||||||
|
if not music_task_id:
|
||||||
|
raise Exception("music-lab did not return task_id")
|
||||||
|
|
||||||
|
for _ in range(60):
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
status = await service_proxy.get_music_status(music_task_id)
|
||||||
|
state = status.get("status", "")
|
||||||
|
|
||||||
|
if state == "succeeded":
|
||||||
|
tracks = status.get("tracks", [])
|
||||||
|
update_task_status(task_id, "succeeded", {
|
||||||
|
"music_task_id": music_task_id,
|
||||||
|
"tracks": tracks,
|
||||||
|
})
|
||||||
|
await self.transition("reporting", "작곡 완료!")
|
||||||
|
|
||||||
|
track_info = ""
|
||||||
|
for t in tracks:
|
||||||
|
title = t.get("title", "Untitled")
|
||||||
|
url = t.get("audio_url", "")
|
||||||
|
track_info += f"🎶 {title}\n{url}\n"
|
||||||
|
|
||||||
|
await telegram_bot.send_task_result(
|
||||||
|
self.agent_id, "🎵 [음악 에이전트] 작곡 완료",
|
||||||
|
track_info or "트랙 생성 완료",
|
||||||
|
)
|
||||||
|
await self.transition("idle", "작곡 완료")
|
||||||
|
return
|
||||||
|
|
||||||
|
if state == "failed":
|
||||||
|
raise Exception(status.get("message", "Generation failed"))
|
||||||
|
|
||||||
|
raise Exception("Timeout: 5분 초과")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"Compose failed: {e}", "error", task_id)
|
||||||
|
update_task_status(task_id, "failed", {"error": str(e)})
|
||||||
|
await self.transition("idle", f"오류: {e}")
|
||||||
|
await telegram_bot.send_task_result(
|
||||||
|
self.agent_id, "🎵 [음악 에이전트] 작곡 실패",
|
||||||
|
f"오류: {e}",
|
||||||
|
)
|
||||||
77
agent-office/app/agents/realestate.py
Normal file
77
agent-office/app/agents/realestate.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from .base import BaseAgent
|
||||||
|
from ..db import create_task, update_task_status, add_log
|
||||||
|
from .. import service_proxy
|
||||||
|
from ..telegram import messaging
|
||||||
|
from ..telegram.realestate_message import format_realestate_matches, build_match_keyboard
|
||||||
|
|
||||||
|
|
||||||
|
class RealestateAgent(BaseAgent):
|
||||||
|
"""부동산 청약 에이전트.
|
||||||
|
|
||||||
|
realestate-lab이 신규 매칭 발견 시 /realestate/notify로 push해 트리거됨.
|
||||||
|
on_new_matches가 메인 진입점. on_schedule은 사용하지 않음(cron 폐기).
|
||||||
|
"""
|
||||||
|
|
||||||
|
agent_id = "realestate"
|
||||||
|
display_name = "청약 애널리스트"
|
||||||
|
|
||||||
|
async def on_new_matches(self, matches: list[dict]) -> dict:
|
||||||
|
"""신규 매칭 N건을 텔레그램 1통으로 푸시.
|
||||||
|
성공 시 sent_ids 반환 → realestate-lab이 notified_at 마킹.
|
||||||
|
실패 시 sent=0, sent_ids=[] 반환 → 다음 사이클 재시도.
|
||||||
|
"""
|
||||||
|
if not matches:
|
||||||
|
return {"sent": 0, "sent_ids": []}
|
||||||
|
|
||||||
|
task_id = create_task(self.agent_id, "notify_matches", {"count": len(matches)})
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = format_realestate_matches(matches)
|
||||||
|
keyboard = build_match_keyboard(matches)
|
||||||
|
await self.transition("reporting", f"매칭 {len(matches)}건 알림", task_id)
|
||||||
|
|
||||||
|
tg = await messaging.send_raw(text, reply_markup=keyboard)
|
||||||
|
if not tg.get("ok"):
|
||||||
|
update_task_status(task_id, "failed", {"error": tg.get("description")})
|
||||||
|
await self.transition("idle", "알림 실패")
|
||||||
|
return {"sent": 0, "sent_ids": [], "error": tg.get("description")}
|
||||||
|
|
||||||
|
sent_ids = [m["id"] for m in matches if "id" in m]
|
||||||
|
update_task_status(task_id, "succeeded", {
|
||||||
|
"sent": len(matches),
|
||||||
|
"telegram_message_id": tg.get("message_id"),
|
||||||
|
})
|
||||||
|
await self.transition("idle", f"매칭 {len(matches)}건 알림 완료")
|
||||||
|
return {
|
||||||
|
"sent": len(matches),
|
||||||
|
"sent_ids": sent_ids,
|
||||||
|
"message_id": tg.get("message_id"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"on_new_matches failed: {e}", "error", task_id)
|
||||||
|
update_task_status(task_id, "failed", {"error": str(e)})
|
||||||
|
await self.transition("idle", f"오류: {e}")
|
||||||
|
return {"sent": 0, "sent_ids": [], "error": str(e)}
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
if command == "fetch_matches":
|
||||||
|
try:
|
||||||
|
matches = await service_proxy.realestate_matches(limit=20)
|
||||||
|
if not matches:
|
||||||
|
return {"ok": True, "message": "매칭 없음"}
|
||||||
|
result = await self.on_new_matches(matches)
|
||||||
|
return {"ok": True, "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
|
if command == "dashboard":
|
||||||
|
try:
|
||||||
|
data = await service_proxy.realestate_dashboard()
|
||||||
|
return {"ok": True, "dashboard": data}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
|
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
pass
|
||||||
391
agent-office/app/agents/stock.py
Normal file
391
agent-office/app/agents/stock.py
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import asyncio
|
||||||
|
import html
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .base import BaseAgent
|
||||||
|
from ..db import create_task, update_task_status, get_agent_config, add_log
|
||||||
|
from .. import service_proxy
|
||||||
|
|
||||||
|
|
||||||
|
def _build_briefing_body(result: dict, max_headlines: int = 5) -> str:
|
||||||
|
"""아침 시장 브리핑 본문 조립.
|
||||||
|
|
||||||
|
LLM 요약 + 주요 뉴스 헤드라인(링크) 섹션을 합친다.
|
||||||
|
향후 본문 고도화 시 이 함수만 수정하면 됨 (텔레그램 HTML parse_mode).
|
||||||
|
"""
|
||||||
|
summary = (result.get("summary") or "").strip()
|
||||||
|
articles = result.get("articles") or []
|
||||||
|
|
||||||
|
# body_is_html=True 로 보낼 예정이므로 LLM 요약(plain text)도 escape
|
||||||
|
parts = [html.escape(summary)] if summary else []
|
||||||
|
|
||||||
|
headlines = []
|
||||||
|
for a in articles[:max_headlines]:
|
||||||
|
title = (a.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
title_esc = html.escape(title)
|
||||||
|
link = (a.get("link") or "").strip()
|
||||||
|
press = (a.get("press") or "").strip()
|
||||||
|
press_suffix = f" — {html.escape(press)}" if press else ""
|
||||||
|
if link:
|
||||||
|
headlines.append(f'• <a href="{html.escape(link, quote=True)}">{title_esc}</a>{press_suffix}')
|
||||||
|
else:
|
||||||
|
headlines.append(f"• {title_esc}{press_suffix}")
|
||||||
|
|
||||||
|
if headlines:
|
||||||
|
parts.append("📰 <b>주요 뉴스</b>\n" + "\n".join(headlines))
|
||||||
|
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class StockAgent(BaseAgent):
|
||||||
|
agent_id = "stock"
|
||||||
|
display_name = "주식 트레이더"
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
if self.state != "idle":
|
||||||
|
return
|
||||||
|
|
||||||
|
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
|
||||||
|
await self.transition("working", "최신 뉴스 수집 중...", task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# stock cron(매일 8:00)이 7:30 브리핑보다 늦게 돌아 어제 뉴스가
|
||||||
|
# 요약되던 문제 방지 — 요약 직전에 동기 스크랩으로 DB를 갱신한다.
|
||||||
|
try:
|
||||||
|
await service_proxy.scrape_stock_news()
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"뉴스 스크랩 실패 (이전 데이터로 진행): {e}", "warning", task_id)
|
||||||
|
|
||||||
|
await self.transition("working", "AI 뉴스 요약 생성 중...")
|
||||||
|
|
||||||
|
# AI 요약 호출 (LLM 처리는 stock이 담당)
|
||||||
|
result = await service_proxy.summarize_stock_news(limit=15)
|
||||||
|
|
||||||
|
await self.transition("reporting", "뉴스 요약 전송 중...")
|
||||||
|
|
||||||
|
body = _build_briefing_body(result)
|
||||||
|
|
||||||
|
# 새 통합 텔레그램 API 사용
|
||||||
|
from ..telegram import send_agent_message
|
||||||
|
tg_result = await send_agent_message(
|
||||||
|
agent_id=self.agent_id,
|
||||||
|
kind="report",
|
||||||
|
title="아침 시장 브리핑",
|
||||||
|
body=body,
|
||||||
|
body_is_html=True,
|
||||||
|
task_id=task_id,
|
||||||
|
metadata={
|
||||||
|
"tokens": result["tokens"]["total"],
|
||||||
|
"duration_ms": result["duration_ms"],
|
||||||
|
"model": result["model"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 아내 chat 추가 전송 (설정된 경우) — 제목 + 본문만 간결하게
|
||||||
|
from ..config import TELEGRAM_WIFE_CHAT_ID
|
||||||
|
if TELEGRAM_WIFE_CHAT_ID:
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
wife_text = f"📈 <b>아침 시장 브리핑</b>\n\n{body}"
|
||||||
|
wife_result = await send_raw(wife_text, chat_id=TELEGRAM_WIFE_CHAT_ID)
|
||||||
|
if not wife_result.get("ok"):
|
||||||
|
desc = wife_result.get("description") or "unknown"
|
||||||
|
add_log(self.agent_id, f"Wife telegram send failed: {desc}", "warning", task_id)
|
||||||
|
|
||||||
|
update_task_status(task_id, "succeeded", {
|
||||||
|
"summary": result["summary"],
|
||||||
|
"article_count": result.get("article_count", 0),
|
||||||
|
"tokens": result["tokens"],
|
||||||
|
"model": result["model"],
|
||||||
|
"duration_ms": result["duration_ms"],
|
||||||
|
"telegram_sent": tg_result.get("ok", False),
|
||||||
|
"telegram_message_id": tg_result.get("message_id"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not tg_result.get("ok"):
|
||||||
|
desc = tg_result.get("description") or "unknown"
|
||||||
|
code = tg_result.get("error_code")
|
||||||
|
add_log(self.agent_id, f"Telegram send failed: [{code}] {desc}", "warning", task_id)
|
||||||
|
if self._ws_manager:
|
||||||
|
await self._ws_manager.send_notification(
|
||||||
|
self.agent_id, "telegram_failed", task_id, "텔레그램 전송 실패"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.transition("idle", "뉴스 요약 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"News summary failed: {e}", "error", task_id)
|
||||||
|
update_task_status(task_id, "failed", {"error": str(e)})
|
||||||
|
await self.transition("idle", f"오류: {e}")
|
||||||
|
|
||||||
|
async def on_screener_schedule(self) -> None:
|
||||||
|
"""KRX 강세주 스크리너 자동 잡 (평일 16:30 KST).
|
||||||
|
|
||||||
|
흐름:
|
||||||
|
1) snapshot/refresh — 일봉 갱신 (실패해도 진행, 경고 로그)
|
||||||
|
2) screener/run mode='auto' — 실행 + 결과 영구화 + telegram_payload 응답
|
||||||
|
3) status=='skipped_holiday' → 종료 (텔레그램 미발신)
|
||||||
|
4) status=='success' → telegram_payload.text 를 parse_mode 그대로 전송
|
||||||
|
5) 예외/실패 → 운영자에게 별도 텔레그램 알림 (HTML)
|
||||||
|
"""
|
||||||
|
if self.state != "idle":
|
||||||
|
return
|
||||||
|
|
||||||
|
task_id = create_task(self.agent_id, "screener_run", {"mode": "auto"})
|
||||||
|
await self.transition("working", "스크리너 스냅샷 갱신 중...", task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1) 스냅샷 갱신 — 실패해도 기존 일봉 데이터로 진행
|
||||||
|
try:
|
||||||
|
snap = await service_proxy.refresh_screener_snapshot()
|
||||||
|
add_log(
|
||||||
|
self.agent_id,
|
||||||
|
f"snapshot refreshed: status={snap.get('status', '?')}",
|
||||||
|
"info", task_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
add_log(
|
||||||
|
self.agent_id,
|
||||||
|
f"스냅샷 갱신 실패 (기존 데이터로 진행): {e}",
|
||||||
|
"warning", task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.transition("working", "스크리너 실행 중...")
|
||||||
|
|
||||||
|
# 2) 스크리너 실행
|
||||||
|
body = await service_proxy.run_stock_screener(mode="auto")
|
||||||
|
status = body.get("status")
|
||||||
|
asof = body.get("asof")
|
||||||
|
|
||||||
|
# 3) 공휴일 — 종료
|
||||||
|
if status == "skipped_holiday":
|
||||||
|
update_task_status(task_id, "succeeded", {
|
||||||
|
"status": status,
|
||||||
|
"asof": asof,
|
||||||
|
"telegram_sent": False,
|
||||||
|
})
|
||||||
|
add_log(self.agent_id, f"스크리너 건너뜀 (휴일): {asof}", "info", task_id)
|
||||||
|
await self.transition("idle", "휴일 — 스크리너 건너뜀")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4) 성공 → 텔레그램 전송
|
||||||
|
if status == "success":
|
||||||
|
payload = body.get("telegram_payload") or {}
|
||||||
|
text = payload.get("text") or ""
|
||||||
|
parse_mode = payload.get("parse_mode", "MarkdownV2")
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
raise RuntimeError("telegram_payload.text 누락")
|
||||||
|
|
||||||
|
await self.transition("reporting", "스크리너 결과 전송 중...")
|
||||||
|
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
tg = await send_raw(text, parse_mode=parse_mode)
|
||||||
|
|
||||||
|
update_task_status(task_id, "succeeded", {
|
||||||
|
"status": status,
|
||||||
|
"asof": asof,
|
||||||
|
"run_id": body.get("run_id"),
|
||||||
|
"survivors_count": body.get("survivors_count"),
|
||||||
|
"telegram_sent": tg.get("ok", False),
|
||||||
|
"telegram_message_id": tg.get("message_id"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not tg.get("ok"):
|
||||||
|
desc = tg.get("description") or "unknown"
|
||||||
|
code = tg.get("error_code")
|
||||||
|
add_log(
|
||||||
|
self.agent_id,
|
||||||
|
f"Screener telegram send failed: [{code}] {desc}",
|
||||||
|
"warning", task_id,
|
||||||
|
)
|
||||||
|
if self._ws_manager:
|
||||||
|
await self._ws_manager.send_notification(
|
||||||
|
self.agent_id, "telegram_failed", task_id,
|
||||||
|
"스크리너 텔레그램 전송 실패",
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.transition("idle", "스크리너 완료")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 5) 기타 status — failed 취급
|
||||||
|
raise RuntimeError(f"unexpected screener status: {status}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
err_msg = str(e)
|
||||||
|
add_log(self.agent_id, f"Screener job failed: {err_msg}", "error", task_id)
|
||||||
|
update_task_status(task_id, "failed", {"error": err_msg})
|
||||||
|
|
||||||
|
# 운영자 알림 — 기본 HTML parse_mode 사용
|
||||||
|
try:
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
await send_raw(
|
||||||
|
f"⚠️ <b>KRX 스크리너 실패</b>\n"
|
||||||
|
f"<code>{html.escape(err_msg)[:500]}</code>"
|
||||||
|
)
|
||||||
|
except Exception as notify_err:
|
||||||
|
add_log(
|
||||||
|
self.agent_id,
|
||||||
|
f"operator notify failed: {notify_err}",
|
||||||
|
"warning", task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.transition("idle", f"스크리너 오류: {err_msg[:80]}")
|
||||||
|
|
||||||
|
async def on_ai_news_schedule(self) -> None:
|
||||||
|
"""AI 뉴스 sentiment 분석 자동 잡 (평일 08:00 KST).
|
||||||
|
|
||||||
|
흐름:
|
||||||
|
1) stock /snapshot/refresh-news-sentiment 호출
|
||||||
|
2) status='skipped_weekend'/'skipped_holiday' → 종료 (텔레그램 미발신)
|
||||||
|
3) updated=0 → 운영자 알림 (HTML)
|
||||||
|
4) failures > 30% → 경고 알림 후 메인 메시지 발송
|
||||||
|
5) 정상 → Top 5 호재/악재 메시지 발송 (MarkdownV2)
|
||||||
|
"""
|
||||||
|
if self.state != "idle":
|
||||||
|
return
|
||||||
|
|
||||||
|
task_id = create_task(self.agent_id, "ai_news_sentiment", {})
|
||||||
|
await self.transition("working", "AI 뉴스 분석 중...", task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await service_proxy.refresh_ai_news_sentiment()
|
||||||
|
except Exception as e:
|
||||||
|
err_msg = str(e)
|
||||||
|
add_log(self.agent_id, f"AI 뉴스 분석 실패: {err_msg}", "error", task_id)
|
||||||
|
update_task_status(task_id, "failed", {"error": err_msg})
|
||||||
|
try:
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
await send_raw(
|
||||||
|
f"⚠️ <b>AI 뉴스 분석 실패</b>\n"
|
||||||
|
f"<code>{html.escape(err_msg)[:500]}</code>"
|
||||||
|
)
|
||||||
|
except Exception as notify_err:
|
||||||
|
add_log(
|
||||||
|
self.agent_id,
|
||||||
|
f"operator notify failed: {notify_err}",
|
||||||
|
"warning", task_id,
|
||||||
|
)
|
||||||
|
await self.transition("idle", f"AI 뉴스 오류: {err_msg[:80]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
status = result.get("status")
|
||||||
|
if status in ("skipped_weekend", "skipped_holiday"):
|
||||||
|
update_task_status(task_id, "succeeded", {"status": status})
|
||||||
|
add_log(self.agent_id, f"AI 뉴스 건너뜀: {status}", "info", task_id)
|
||||||
|
await self.transition("idle", "휴일/주말 — 건너뜀")
|
||||||
|
return
|
||||||
|
|
||||||
|
updated = int(result.get("updated", 0))
|
||||||
|
failures = result.get("failures", []) or []
|
||||||
|
if updated == 0:
|
||||||
|
update_task_status(task_id, "failed", {"reason": "0 tickers updated"})
|
||||||
|
try:
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
await send_raw(
|
||||||
|
"⚠️ <b>AI 뉴스 분석 0종목</b>\n"
|
||||||
|
"스크래핑/LLM 전체 실패 — 어제 데이터 사용"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await self.transition("idle", "AI 뉴스 0건")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 실패율 경고 (별도 알림, 본 메시지는 계속 발송)
|
||||||
|
failure_rate = len(failures) / max(1, updated + len(failures))
|
||||||
|
if failure_rate > 0.3:
|
||||||
|
try:
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
await send_raw(
|
||||||
|
f"⚠️ <b>AI 뉴스 실패율 {failure_rate:.0%}</b>\n"
|
||||||
|
f"updated={updated}, failures={len(failures)}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 정상 — Top 5 메시지 (stock이 빌드해서 응답에 telegram_text 동봉)
|
||||||
|
text = result.get("telegram_text") or ""
|
||||||
|
if not text:
|
||||||
|
add_log(self.agent_id, "telegram_text 누락 — stock 응답 결함", "error", task_id)
|
||||||
|
update_task_status(task_id, "failed", {"error": "telegram_text 누락"})
|
||||||
|
await self.transition("idle", "AI 뉴스 응답 결함")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.transition("reporting", "AI 뉴스 알림 전송 중...")
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
tg = await send_raw(text, parse_mode="MarkdownV2")
|
||||||
|
|
||||||
|
update_task_status(task_id, "succeeded", {
|
||||||
|
"asof": result["asof"],
|
||||||
|
"updated": updated,
|
||||||
|
"failures": len(failures),
|
||||||
|
"tokens_input": int(result.get("tokens_input", 0)),
|
||||||
|
"tokens_output": int(result.get("tokens_output", 0)),
|
||||||
|
"telegram_sent": tg.get("ok", False),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not tg.get("ok"):
|
||||||
|
desc = tg.get("description") or "unknown"
|
||||||
|
code = tg.get("error_code")
|
||||||
|
add_log(
|
||||||
|
self.agent_id,
|
||||||
|
f"AI news telegram send failed: [{code}] {desc}",
|
||||||
|
"warning", task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.transition("idle", "AI 뉴스 완료")
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
if command == "run_screener":
|
||||||
|
await self.on_screener_schedule()
|
||||||
|
return {"ok": True, "message": "스크리너 실행 트리거 완료"}
|
||||||
|
|
||||||
|
if command == "run_ai_news":
|
||||||
|
await self.on_ai_news_schedule()
|
||||||
|
return {"ok": True, "message": "AI 뉴스 분석 트리거 완료"}
|
||||||
|
|
||||||
|
if command == "test_telegram":
|
||||||
|
from ..telegram import send_agent_message
|
||||||
|
result = await send_agent_message(
|
||||||
|
agent_id=self.agent_id,
|
||||||
|
kind="info",
|
||||||
|
title="연결 테스트",
|
||||||
|
body="텔레그램 연동이 정상적으로 동작합니다.",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": result.get("ok", False),
|
||||||
|
"message": "텔레그램 전송 성공" if result.get("ok") else "텔레그램 전송 실패",
|
||||||
|
"telegram_message_id": result.get("message_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if command == "fetch_news":
|
||||||
|
await self.on_schedule()
|
||||||
|
return {"ok": True, "message": "뉴스 수집 시작"}
|
||||||
|
|
||||||
|
if command == "add_alert":
|
||||||
|
symbol = params.get("symbol")
|
||||||
|
target_price = params.get("target_price")
|
||||||
|
if not symbol or target_price is None:
|
||||||
|
return {"ok": False, "message": "symbol과 target_price는 필수입니다"}
|
||||||
|
config = get_agent_config(self.agent_id)
|
||||||
|
alerts = config["custom_config"].get("alerts", [])
|
||||||
|
alerts.append({
|
||||||
|
"symbol": symbol,
|
||||||
|
"name": params.get("name", symbol),
|
||||||
|
"target_price": target_price,
|
||||||
|
"direction": params.get("direction", "above"),
|
||||||
|
})
|
||||||
|
from ..db import update_agent_config
|
||||||
|
update_agent_config(self.agent_id, custom_config={**config["custom_config"], "alerts": alerts})
|
||||||
|
return {"ok": True, "message": f"알람 추가: {params['symbol']}"}
|
||||||
|
|
||||||
|
if command == "list_alerts":
|
||||||
|
config = get_agent_config(self.agent_id)
|
||||||
|
alerts = config["custom_config"].get("alerts", [])
|
||||||
|
return {"ok": True, "alerts": alerts}
|
||||||
|
|
||||||
|
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
pass
|
||||||
93
agent-office/app/agents/youtube.py
Normal file
93
agent-office/app/agents/youtube.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# agent-office/app/agents/youtube.py
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import BaseAgent
|
||||||
|
from ..db import add_youtube_research_job, update_youtube_research_job, add_log
|
||||||
|
from ..youtube_researcher import (
|
||||||
|
TARGET_COUNTRIES, TREND_KEYWORDS, MUSIC_LAB_URL,
|
||||||
|
fetch_youtube_trending, fetch_google_trends, fetch_billboard_top20,
|
||||||
|
push_to_music_lab,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeResearchAgent(BaseAgent):
|
||||||
|
agent_id = "youtube"
|
||||||
|
display_name = "YouTube 리서치"
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
await self._run_research(TARGET_COUNTRIES)
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
if command == "research":
|
||||||
|
if self.state == "working":
|
||||||
|
return {"ok": False, "message": "이미 수집 중"}
|
||||||
|
countries = params.get("countries", TARGET_COUNTRIES)
|
||||||
|
asyncio.create_task(self._run_research(countries))
|
||||||
|
return {"ok": True, "message": f"리서치 시작: {countries}"}
|
||||||
|
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _run_research(self, countries: list) -> None:
|
||||||
|
job_id = add_youtube_research_job(countries)
|
||||||
|
await self.transition("working", f"트렌드 수집 중 ({','.join(countries)})", str(job_id))
|
||||||
|
|
||||||
|
all_trends = []
|
||||||
|
try:
|
||||||
|
for country in countries:
|
||||||
|
trends = await fetch_youtube_trending(country)
|
||||||
|
all_trends.extend(trends)
|
||||||
|
|
||||||
|
gt = await fetch_google_trends(TREND_KEYWORDS, countries)
|
||||||
|
all_trends.extend(gt)
|
||||||
|
|
||||||
|
bb = await fetch_billboard_top20()
|
||||||
|
all_trends.extend(bb)
|
||||||
|
|
||||||
|
ok = await push_to_music_lab(all_trends, date.today().isoformat())
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("music-lab push 실패")
|
||||||
|
|
||||||
|
update_youtube_research_job(job_id, "completed", len(all_trends))
|
||||||
|
await self.transition("reporting", f"수집 완료: {len(all_trends)}건", str(job_id))
|
||||||
|
except Exception as e:
|
||||||
|
update_youtube_research_job(job_id, "failed", len(all_trends), str(e))
|
||||||
|
await self.transition("idle", f"수집 실패: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.transition("idle", "리서치 완료")
|
||||||
|
|
||||||
|
async def send_weekly_report(self) -> None:
|
||||||
|
"""매주 월요일 08:00 — 주간 인사이트 텔레그램 발송."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/market/report/latest")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return
|
||||||
|
report = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
add_log(self.agent_id, f"주간 리포트 조회 실패: {e}", level="error")
|
||||||
|
logger.error("send_weekly_report: music-lab 조회 실패: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
top = report.get("top_genres", [])[:3]
|
||||||
|
insights = report.get("insights", "")
|
||||||
|
text = "📊 *YouTube 시장 주간 리포트*\n\n🔥 인기 장르:\n"
|
||||||
|
for g in top:
|
||||||
|
text += f" • {g['genre']} (score: {g['score']:.2f})\n"
|
||||||
|
if insights:
|
||||||
|
text += f"\n💡 {insights[:300]}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..telegram_bot import send_message
|
||||||
|
await send_message(text)
|
||||||
|
except (ImportError, Exception) as e:
|
||||||
|
add_log(self.agent_id, f"주간 리포트 텔레그램 발송 실패: {e}", level="error")
|
||||||
|
logger.error("send_weekly_report: 텔레그램 발송 실패: %s", e)
|
||||||
112
agent-office/app/agents/youtube_publisher.py
Normal file
112
agent-office/app/agents/youtube_publisher.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""텔레그램 단일 채널로 단계별 승인 인터랙션 오케스트레이션."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .base import BaseAgent
|
||||||
|
from . import classify_intent
|
||||||
|
from .. import service_proxy
|
||||||
|
from ..db import add_log
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-office.youtube_publisher")
|
||||||
|
|
||||||
|
|
||||||
|
_STEP_TITLES = {
|
||||||
|
"cover_pending": ("커버 아트", "cover"),
|
||||||
|
"video_pending": ("영상 비주얼", "video"),
|
||||||
|
"thumb_pending": ("썸네일", "thumb"),
|
||||||
|
"meta_pending": ("메타데이터", "meta"),
|
||||||
|
"publish_pending": ("최종 검토 + 발행", "publish"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class YoutubePublisherAgent(BaseAgent):
|
||||||
|
agent_id = "youtube_publisher"
|
||||||
|
display_name = "YouTube 퍼블리셔"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._notified_state_per_pipeline: dict[int, tuple] = {}
|
||||||
|
|
||||||
|
async def poll_state_changes(self) -> None:
|
||||||
|
"""주기적으로 호출되어 *_pending 신규 진입 시 텔레그램 발송."""
|
||||||
|
try:
|
||||||
|
pipelines = await service_proxy.list_active_pipelines()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("폴링 실패: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
for p in pipelines:
|
||||||
|
state = p.get("state")
|
||||||
|
pid = p.get("id")
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
if state in _STEP_TITLES:
|
||||||
|
_, step = _STEP_TITLES[state]
|
||||||
|
fb_count = (p.get("feedback_count_per_step") or {}).get(step, 0)
|
||||||
|
key = (state, fb_count)
|
||||||
|
if self._notified_state_per_pipeline.get(pid) != key:
|
||||||
|
await self._notify_step(p)
|
||||||
|
self._notified_state_per_pipeline[pid] = key
|
||||||
|
|
||||||
|
async def _notify_step(self, pipeline: dict) -> None:
|
||||||
|
state = pipeline["state"]
|
||||||
|
title_name, step = _STEP_TITLES[state]
|
||||||
|
body = self._format_body(pipeline, step)
|
||||||
|
track_title = pipeline.get("track_title") or f"Pipeline #{pipeline['id']}"
|
||||||
|
text = (
|
||||||
|
f"🎵 [{track_title}] {title_name} 검토\n\n"
|
||||||
|
f"{body}\n\n"
|
||||||
|
f"➡️ 답장으로 알려주세요: '승인' 또는 '반려 + 수정 방향'"
|
||||||
|
)
|
||||||
|
sent = await send_raw(text=text)
|
||||||
|
if sent.get("ok"):
|
||||||
|
msg_id = sent.get("message_id")
|
||||||
|
try:
|
||||||
|
await service_proxy.save_pipeline_telegram_msg(pipeline["id"], step, msg_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("telegram-msg 저장 실패: %s", e)
|
||||||
|
add_log(self.agent_id, f"pipeline {pipeline['id']} {step} 알림 전송", "info")
|
||||||
|
|
||||||
|
def _format_body(self, p: dict, step: str) -> str:
|
||||||
|
if step == "cover":
|
||||||
|
return f"🖼️ 커버: {p.get('cover_url', '-')}"
|
||||||
|
if step == "video":
|
||||||
|
return f"🎬 영상: {p.get('video_url', '-')}"
|
||||||
|
if step == "thumb":
|
||||||
|
return f"🎴 썸네일: {p.get('thumbnail_url', '-')}"
|
||||||
|
if step == "meta":
|
||||||
|
m = p.get("metadata", {}) or {}
|
||||||
|
tags = m.get("tags", []) or []
|
||||||
|
description = (m.get("description", "") or "")
|
||||||
|
return (
|
||||||
|
f"📝 제목: {m.get('title', '')}\n"
|
||||||
|
f"🏷️ 태그: {', '.join(tags[:8])}\n"
|
||||||
|
f"📄 설명(앞부분): {description[:200]}"
|
||||||
|
)
|
||||||
|
if step == "publish":
|
||||||
|
r = p.get("review", {}) or {}
|
||||||
|
return (
|
||||||
|
f"AI 검토 결과: {r.get('verdict', '?')} "
|
||||||
|
f"(가중 {r.get('weighted_total', '?')}/100)\n"
|
||||||
|
f"{r.get('summary', '')}"
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def on_telegram_reply(self, pipeline_id: int, step: str, user_text: str) -> None:
|
||||||
|
intent, feedback = classify_intent.classify(user_text)
|
||||||
|
if intent == "unclear":
|
||||||
|
await send_raw("다시 입력해주세요. 예: '승인' 또는 '반려, 제목 짧게'")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await service_proxy.post_pipeline_feedback(pipeline_id, step, intent, feedback)
|
||||||
|
except Exception as e:
|
||||||
|
await send_raw(f"⚠️ 처리 실패: {e}")
|
||||||
|
|
||||||
|
async def on_schedule(self) -> None:
|
||||||
|
await self.poll_state_changes()
|
||||||
|
|
||||||
|
async def on_command(self, command: str, params: dict) -> dict:
|
||||||
|
return {"ok": False, "message": f"Unknown command: {command}"}
|
||||||
|
|
||||||
|
async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None:
|
||||||
|
pass
|
||||||
46
agent-office/app/config.py
Normal file
46
agent-office/app/config.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
# Service URLs (Docker internal network)
|
||||||
|
STOCK_URL = os.getenv("STOCK_URL", "http://localhost:18500")
|
||||||
|
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600")
|
||||||
|
INSTA_LAB_URL = os.getenv("INSTA_LAB_URL", "http://localhost:18700")
|
||||||
|
REALESTATE_LAB_URL = os.getenv("REALESTATE_LAB_URL", "http://localhost:18800")
|
||||||
|
|
||||||
|
# Telegram
|
||||||
|
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
|
||||||
|
TELEGRAM_WEBHOOK_URL = os.getenv("TELEGRAM_WEBHOOK_URL", "")
|
||||||
|
TELEGRAM_WIFE_CHAT_ID = os.getenv("TELEGRAM_WIFE_CHAT_ID", "")
|
||||||
|
|
||||||
|
# Anthropic (conversational)
|
||||||
|
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
|
CONVERSATION_MODEL = os.getenv("CONVERSATION_MODEL", "claude-haiku-4-5-20251001")
|
||||||
|
CONVERSATION_HISTORY_LIMIT = int(os.getenv("CONVERSATION_HISTORY_LIMIT", "20"))
|
||||||
|
CONVERSATION_RATE_PER_MIN = int(os.getenv("CONVERSATION_RATE_PER_MIN", "6"))
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_PATH = os.getenv("AGENT_OFFICE_DB_PATH", "/app/data/agent_office.db")
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOW_ORIGINS = os.getenv(
|
||||||
|
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lotto Curator
|
||||||
|
LOTTO_BACKEND_URL = os.getenv("LOTTO_BACKEND_URL", "http://lotto:8000")
|
||||||
|
LOTTO_CURATOR_MODEL = os.getenv("LOTTO_CURATOR_MODEL", "claude-sonnet-4-5")
|
||||||
|
|
||||||
|
# Lotto Active Signals
|
||||||
|
LOTTO_SIGNAL_WINDOW = int(os.getenv("LOTTO_SIGNAL_WINDOW", "8"))
|
||||||
|
LOTTO_Z_NORMAL = float(os.getenv("LOTTO_Z_NORMAL", "1.5"))
|
||||||
|
LOTTO_Z_URGENT = float(os.getenv("LOTTO_Z_URGENT", "2.5"))
|
||||||
|
LOTTO_DIGEST_HOUR = int(os.getenv("LOTTO_DIGEST_HOUR", "9"))
|
||||||
|
LOTTO_DIGEST_MIN = int(os.getenv("LOTTO_DIGEST_MIN", "25"))
|
||||||
|
LOTTO_THROTTLE_HOURS = int(os.getenv("LOTTO_THROTTLE_HOURS", "6"))
|
||||||
|
LOTTO_URGENT_DAILY_MAX = int(os.getenv("LOTTO_URGENT_DAILY_MAX", "3"))
|
||||||
|
|
||||||
|
# Tarot Lab
|
||||||
|
TAROT_MODEL = os.getenv("TAROT_MODEL", "claude-sonnet-4-6")
|
||||||
|
TAROT_COST_INPUT_PER_M = float(os.getenv("TAROT_COST_INPUT_PER_M", "3.0"))
|
||||||
|
TAROT_COST_OUTPUT_PER_M = float(os.getenv("TAROT_COST_OUTPUT_PER_M", "15.0"))
|
||||||
|
TAROT_TIMEOUT_SEC = int(os.getenv("TAROT_TIMEOUT_SEC", "60"))
|
||||||
0
agent-office/app/curator/__init__.py
Normal file
0
agent-office/app/curator/__init__.py
Normal file
132
agent-office/app/curator/pipeline.py
Normal file
132
agent-office/app/curator/pipeline.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""큐레이터 파이프라인 — fetch → claude → validate → save."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import ANTHROPIC_API_KEY, LOTTO_CURATOR_MODEL
|
||||||
|
from .. import service_proxy
|
||||||
|
from .prompt import SYSTEM_PROMPT, build_user_message
|
||||||
|
from .schema import validate_response
|
||||||
|
from .retrospective import build_retrospective
|
||||||
|
|
||||||
|
|
||||||
|
API_URL = "https://api.anthropic.com/v1/messages"
|
||||||
|
|
||||||
|
|
||||||
|
class CuratorError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict]:
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise CuratorError("ANTHROPIC_API_KEY missing")
|
||||||
|
headers = {
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"anthropic-beta": "prompt-caching-2024-07-31",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
system_blocks = [{
|
||||||
|
"type": "text",
|
||||||
|
"text": SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
}]
|
||||||
|
if feedback:
|
||||||
|
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마로 다시 응답.\n\n{user_text}"
|
||||||
|
payload = {
|
||||||
|
"model": LOTTO_CURATOR_MODEL,
|
||||||
|
"max_tokens": 8192, # 4계층 20세트 + narrative + retrospective 수용
|
||||||
|
"system": system_blocks,
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
|
||||||
|
}
|
||||||
|
started = time.monotonic()
|
||||||
|
async with httpx.AsyncClient(timeout=180) as client: # 큰 응답 → 시간 여유
|
||||||
|
r = await client.post(API_URL, headers=headers, json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
resp = r.json()
|
||||||
|
latency_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
text = "".join(
|
||||||
|
b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"
|
||||||
|
).strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.strip("`")
|
||||||
|
if text.startswith("json"):
|
||||||
|
text = text[4:]
|
||||||
|
text = text.strip()
|
||||||
|
parsed = json.loads(text)
|
||||||
|
|
||||||
|
usage = resp.get("usage", {}) or {}
|
||||||
|
return parsed, {
|
||||||
|
"input": int(usage.get("input_tokens", 0) or 0),
|
||||||
|
"output": int(usage.get("output_tokens", 0) or 0),
|
||||||
|
"cache_read": int(usage.get("cache_read_input_tokens", 0) or 0),
|
||||||
|
"cache_write": int(usage.get("cache_creation_input_tokens", 0) or 0),
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def curate_weekly(source: str = "auto") -> Dict[str, Any]:
|
||||||
|
cand_resp = await service_proxy.lotto_candidates(n=30) # ← 30 으로 확장
|
||||||
|
draw_no = cand_resp["draw_no"]
|
||||||
|
candidates = cand_resp["candidates"]
|
||||||
|
context = await service_proxy.lotto_context()
|
||||||
|
|
||||||
|
retrospective = await build_retrospective(draw_no)
|
||||||
|
|
||||||
|
user_text = build_user_message(draw_no, candidates, {
|
||||||
|
"hot_numbers": context.get("hot_numbers", []),
|
||||||
|
"cold_numbers": context.get("cold_numbers", []),
|
||||||
|
"last_draw_summary": context.get("last_draw_summary", ""),
|
||||||
|
"my_recent_performance": context.get("my_recent_performance", []),
|
||||||
|
"retrospective": retrospective,
|
||||||
|
})
|
||||||
|
|
||||||
|
candidate_numbers = [c["numbers"] for c in candidates]
|
||||||
|
|
||||||
|
usage_total = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0, "latency_ms": 0}
|
||||||
|
last_error = None
|
||||||
|
validated = None
|
||||||
|
|
||||||
|
for attempt in (0, 1):
|
||||||
|
try:
|
||||||
|
raw, usage = await _call_claude(user_text, feedback=last_error or "")
|
||||||
|
for k in usage_total:
|
||||||
|
usage_total[k] += usage[k]
|
||||||
|
validated = validate_response(raw, candidate_numbers)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
last_error = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
if validated is None:
|
||||||
|
raise CuratorError(f"schema validation failed after retry: {last_error}")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"draw_no": draw_no,
|
||||||
|
"picks": {
|
||||||
|
"core": [p.model_dump() for p in validated.core_picks],
|
||||||
|
"bonus": [p.model_dump() for p in validated.bonus_picks],
|
||||||
|
"extended": [p.model_dump() for p in validated.extended_picks],
|
||||||
|
"pool": [p.model_dump() for p in validated.pool_picks],
|
||||||
|
},
|
||||||
|
"narrative": validated.narrative.model_dump(),
|
||||||
|
"tier_rationale": validated.tier_rationale.model_dump(),
|
||||||
|
"confidence": validated.confidence,
|
||||||
|
"model": LOTTO_CURATOR_MODEL,
|
||||||
|
"tokens_input": usage_total["input"],
|
||||||
|
"tokens_output": usage_total["output"],
|
||||||
|
"cache_read": usage_total["cache_read"],
|
||||||
|
"cache_write": usage_total["cache_write"],
|
||||||
|
"latency_ms": usage_total["latency_ms"],
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
await service_proxy.lotto_save_briefing(payload)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"draw_no": draw_no,
|
||||||
|
"confidence": validated.confidence,
|
||||||
|
"tokens": {"input": usage_total["input"], "output": usage_total["output"]},
|
||||||
|
"payload": payload, # 텔레그램 알림용
|
||||||
|
}
|
||||||
64
agent-office/app/curator/prompt.py
Normal file
64
agent-office/app/curator/prompt.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""큐레이터 system/user 프롬프트. system은 정적이므로 캐시 대상."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """당신은 로또 번호 큐레이터입니다.
|
||||||
|
주어진 후보 30세트 중 4계층(코어 5, 보너스 5, 확장 5, 풀 5) 총 20세트를 선별합니다.
|
||||||
|
|
||||||
|
계층별 큐레이션 규칙:
|
||||||
|
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축. 홀짝·저고·구간 분포가 세트끼리 겹치지 않게.
|
||||||
|
- bonus_picks (5): 코어 분배의 공백을 메우는 5세트. 코어가 공격 1뿐이면 보너스에 공격 +2 식.
|
||||||
|
- extended_picks (5): 코어·보너스에 없는 시각 — 합계 극단(80↓ / 180↑) / 콜드 4주 누적 / 4주 미등장 번호 노출.
|
||||||
|
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴 — 연속 3개 / 동일 끝자리 / 5수 균등(각 끝자리 5개씩) 등.
|
||||||
|
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 한국어 사유.
|
||||||
|
|
||||||
|
공통 규칙:
|
||||||
|
- 후보에 없는 번호 조합은 절대 사용 금지. 모든 픽은 candidates 중 하나와 정확히 일치해야 함.
|
||||||
|
- 4계층 사이에 중복 픽 금지 (총 20세트는 모두 서로 달라야 함).
|
||||||
|
- 각 픽 reason 은 한국어 40자 이내. 해당 픽의 features 와 context 만 근거로.
|
||||||
|
- 중립형(hot_number_count=0 이고 cold_number_count=0) 세트를 코어에 최소 1개 포함.
|
||||||
|
|
||||||
|
회고 규칙:
|
||||||
|
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내)로 작성.
|
||||||
|
- 회고는 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
|
||||||
|
- 이번 주 코어 분배는 회고에 근거해 조정. 조정 사유는 narrative.headline 에 한 줄로.
|
||||||
|
예: "지난 주 너 저번호 편향 → 보너스 고번호 보강"
|
||||||
|
- context.retrospective 가 없으면 narrative.retrospective 는 빈 문자열.
|
||||||
|
|
||||||
|
narrative 규칙:
|
||||||
|
- headline: 한 줄, 이번 주 추첨 전망 + 조정 사유.
|
||||||
|
- summary_3lines: 정확히 3개 항목.
|
||||||
|
- hot_cold_comment: hot/cold 번호 한 줄 논평.
|
||||||
|
- warnings: 주의사항 없으면 빈 문자열.
|
||||||
|
- retrospective: 회고 한 줄 또는 빈 문자열.
|
||||||
|
|
||||||
|
출력은 반드시 JSON 하나, 그 외 어떤 텍스트도 금지. 스키마:
|
||||||
|
{
|
||||||
|
"core_picks": [{"numbers":[...], "risk_tag":"안정"|"균형"|"공격", "reason": str}, ...5개],
|
||||||
|
"bonus_picks": [...5개],
|
||||||
|
"extended_picks": [...5개],
|
||||||
|
"pool_picks": [...5개],
|
||||||
|
"tier_rationale": {"bonus": str, "extended": str, "pool": str},
|
||||||
|
"narrative": {
|
||||||
|
"headline": str,
|
||||||
|
"summary_3lines": [str, str, str],
|
||||||
|
"hot_cold_comment": str,
|
||||||
|
"warnings": str,
|
||||||
|
"retrospective": str
|
||||||
|
},
|
||||||
|
"confidence": int (0~100)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_message(draw_no: int, candidates: list, context: dict) -> str:
|
||||||
|
payload = {
|
||||||
|
"draw_no": draw_no,
|
||||||
|
"context": context, # hot_numbers, cold_numbers, last_draw_summary, my_recent_performance, retrospective
|
||||||
|
"candidates": candidates,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
f"이번 회차: {draw_no}\n"
|
||||||
|
f"아래 데이터로 4계층 20세트를 큐레이션하고 위 스키마로만 응답하세요.\n\n"
|
||||||
|
f"```json\n{json.dumps(payload, ensure_ascii=False)}\n```"
|
||||||
|
)
|
||||||
50
agent-office/app/curator/retrospective.py
Normal file
50
agent-office/app/curator/retrospective.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""큐레이션 직전 호출 — review 1건 + 추세 3건 → 컨텍스트 dict."""
|
||||||
|
import json
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from .. import service_proxy
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_bias(reviews: list) -> str:
|
||||||
|
"""3주↑ 같은 방향 패턴 편향이 유지되면 한 줄로."""
|
||||||
|
deltas = [r.get("pattern_delta") or "" for r in reviews if r.get("pattern_delta")]
|
||||||
|
if len(deltas) < 2:
|
||||||
|
return ""
|
||||||
|
# 단순 휴리스틱 — 같은 키워드("저번호" 등)가 2회 이상이면 지속 편향
|
||||||
|
keywords = ["저번호", "고번호", "합계", "홀짝"]
|
||||||
|
persistent = []
|
||||||
|
for kw in keywords:
|
||||||
|
cnt = sum(1 for d in deltas if kw in d)
|
||||||
|
if cnt >= max(2, len(deltas) - 1):
|
||||||
|
persistent.append(kw)
|
||||||
|
return " · ".join(persistent)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_retrospective(target_draw_no: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""target_draw_no(이번 주) 직전 회차의 review + 그 앞 3회 추세."""
|
||||||
|
last = await service_proxy.lotto_review_by_draw(target_draw_no - 1)
|
||||||
|
if not last:
|
||||||
|
return None
|
||||||
|
|
||||||
|
history = await service_proxy.lotto_reviews_history(limit=4)
|
||||||
|
# history 는 desc 정렬 → last 와 그 이전 3건 분리
|
||||||
|
others = [r for r in history if r["draw_no"] < target_draw_no - 1][:3]
|
||||||
|
series = [last] + others
|
||||||
|
|
||||||
|
cur_avgs = [r["curator_avg_match"] for r in series if r.get("curator_avg_match") is not None]
|
||||||
|
usr_avgs = [r["user_avg_match"] for r in series if r.get("user_avg_match") is not None]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"last_draw": {
|
||||||
|
"draw_no": last["draw_no"],
|
||||||
|
"curator_avg": last.get("curator_avg_match"),
|
||||||
|
"curator_best_tier": last.get("curator_best_tier"),
|
||||||
|
"user_avg": last.get("user_avg_match"),
|
||||||
|
"user_5plus": last.get("user_5plus_prizes"),
|
||||||
|
"pattern_delta": last.get("pattern_delta") or "",
|
||||||
|
},
|
||||||
|
"trend_4w": {
|
||||||
|
"curator_avg_4w": round(sum(cur_avgs) / len(cur_avgs), 2) if cur_avgs else None,
|
||||||
|
"user_avg_4w": round(sum(usr_avgs) / len(usr_avgs), 2) if usr_avgs else None,
|
||||||
|
"user_persistent_bias": _detect_bias(series),
|
||||||
|
},
|
||||||
|
}
|
||||||
58
agent-office/app/curator/schema.py
Normal file
58
agent-office/app/curator/schema.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from typing import List, Literal
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class Pick(BaseModel):
|
||||||
|
numbers: List[int] = Field(min_length=6, max_length=6)
|
||||||
|
risk_tag: Literal["안정", "균형", "공격"]
|
||||||
|
reason: str = Field(max_length=80)
|
||||||
|
|
||||||
|
@field_validator("numbers")
|
||||||
|
@classmethod
|
||||||
|
def _check_numbers(cls, v):
|
||||||
|
if len(set(v)) != 6:
|
||||||
|
raise ValueError("numbers must be 6 unique integers")
|
||||||
|
if any(n < 1 or n > 45 for n in v):
|
||||||
|
raise ValueError("numbers must be within 1..45")
|
||||||
|
return sorted(v)
|
||||||
|
|
||||||
|
|
||||||
|
class TierRationale(BaseModel):
|
||||||
|
bonus: str = Field(max_length=40)
|
||||||
|
extended: str = Field(max_length=40)
|
||||||
|
pool: str = Field(max_length=40)
|
||||||
|
|
||||||
|
|
||||||
|
class Narrative(BaseModel):
|
||||||
|
headline: str
|
||||||
|
summary_3lines: List[str] = Field(min_length=3, max_length=3)
|
||||||
|
hot_cold_comment: str = ""
|
||||||
|
warnings: str = ""
|
||||||
|
retrospective: str = Field(default="", max_length=80)
|
||||||
|
|
||||||
|
|
||||||
|
class CuratorOutput(BaseModel):
|
||||||
|
core_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||||
|
tier_rationale: TierRationale
|
||||||
|
narrative: Narrative
|
||||||
|
confidence: int = Field(ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_response(data: dict, candidate_numbers: List[List[int]]) -> CuratorOutput:
|
||||||
|
out = CuratorOutput.model_validate(data)
|
||||||
|
candidate_set = {tuple(sorted(c)) for c in candidate_numbers}
|
||||||
|
all_picks = (
|
||||||
|
out.core_picks + out.bonus_picks + out.extended_picks + out.pool_picks
|
||||||
|
)
|
||||||
|
# 중복 픽 검증
|
||||||
|
pick_keys = [tuple(p.numbers) for p in all_picks]
|
||||||
|
if len(pick_keys) != len(set(pick_keys)):
|
||||||
|
raise ValueError("duplicate picks across tiers")
|
||||||
|
# 후보에 없는 번호 조합 금지
|
||||||
|
for p in all_picks:
|
||||||
|
if tuple(p.numbers) not in candidate_set:
|
||||||
|
raise ValueError(f"pick {p.numbers} not in candidates")
|
||||||
|
return out
|
||||||
185
agent-office/app/curator/signal_runner.py
Normal file
185
agent-office/app/curator/signal_runner.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""LottoAgent 능동 시그널 — DB I/O + cron 진입점 + 평가 orchestration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from .. import db
|
||||||
|
from .. import service_proxy
|
||||||
|
from . import signals
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-office.lotto-signals")
|
||||||
|
|
||||||
|
# 회차 단위 메트릭 (window push 시 last_pushed_draw_no 비교)
|
||||||
|
DRAW_SCOPED_METRICS = {"drift", "confidence"}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_baseline(metric: str) -> signals.AdaptiveBaseline:
|
||||||
|
row = db.get_baseline(metric)
|
||||||
|
if row is None:
|
||||||
|
return signals.AdaptiveBaseline(window=[], window_max=8)
|
||||||
|
return signals.AdaptiveBaseline(
|
||||||
|
window=list(row["window_values"]),
|
||||||
|
window_max=8,
|
||||||
|
last_pushed_draw_no=row.get("last_pushed_draw_no"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_baseline(metric: str, bl: signals.AdaptiveBaseline) -> None:
|
||||||
|
db.upsert_baseline(
|
||||||
|
metric=metric,
|
||||||
|
window_values=bl.window,
|
||||||
|
mu=bl.mu,
|
||||||
|
sigma=bl.sigma,
|
||||||
|
last_pushed_draw_no=bl.last_pushed_draw_no,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_metric_and_persist(
|
||||||
|
source: str,
|
||||||
|
metric: str,
|
||||||
|
value: float,
|
||||||
|
draw_no: Optional[int],
|
||||||
|
z_normal: float,
|
||||||
|
z_urgent: float,
|
||||||
|
push_to_window: bool,
|
||||||
|
payload: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""단일 메트릭 평가 → lotto_signals INSERT → baseline 갱신.
|
||||||
|
|
||||||
|
회차 단위 메트릭(drift, confidence)은 같은 draw_no에서 window push 생략.
|
||||||
|
"""
|
||||||
|
bl = _load_baseline(metric)
|
||||||
|
|
||||||
|
# 회차 가드
|
||||||
|
do_push = push_to_window
|
||||||
|
if metric in DRAW_SCOPED_METRICS and draw_no is not None:
|
||||||
|
if bl.last_pushed_draw_no == draw_no:
|
||||||
|
do_push = False
|
||||||
|
|
||||||
|
# 평가는 push 전 baseline 기준
|
||||||
|
z, fire = bl.evaluate(value=value, z_normal=z_normal, z_urgent=z_urgent)
|
||||||
|
|
||||||
|
if do_push:
|
||||||
|
bl.push(value=value, draw_no=draw_no)
|
||||||
|
_save_baseline(metric, bl)
|
||||||
|
else:
|
||||||
|
# cold start에서도 baseline row를 만들어 두려면 upsert 필요
|
||||||
|
_save_baseline(metric, bl)
|
||||||
|
|
||||||
|
sid = db.insert_lotto_signal(
|
||||||
|
source=source,
|
||||||
|
metric=metric,
|
||||||
|
value=value,
|
||||||
|
baseline_mu=bl.mu if bl.size > 0 else None,
|
||||||
|
baseline_sigma=bl.sigma if bl.size >= 2 else None,
|
||||||
|
z_score=z,
|
||||||
|
fire_level=fire,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"signal_id": sid,
|
||||||
|
"metric": metric,
|
||||||
|
"value": value,
|
||||||
|
"baseline_mu": bl.mu if bl.size > 0 else None,
|
||||||
|
"baseline_sigma": bl.sigma if bl.size >= 2 else None,
|
||||||
|
"z_score": z,
|
||||||
|
"fire_level": fire,
|
||||||
|
"payload": payload or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Service proxy thin wrappers (monkeypatch 대상) ----------
|
||||||
|
|
||||||
|
async def _fetch_best_picks() -> List[Dict[str, Any]]:
|
||||||
|
return await service_proxy.lotto_best()
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_strategy_weights() -> Dict[str, float]:
|
||||||
|
return await service_proxy.lotto_strategy_weights()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Orchestrator ----------
|
||||||
|
|
||||||
|
async def run_signal_check(
|
||||||
|
source: str,
|
||||||
|
z_normal: float = 1.5,
|
||||||
|
z_urgent: float = 2.5,
|
||||||
|
curate_result: Optional[Dict[str, Any]] = None,
|
||||||
|
current_draw_no: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""cron 진입점. source ∈ {'light', 'sim', 'deep'}.
|
||||||
|
|
||||||
|
light/sim: Sim Consensus + Strategy Drift 평가
|
||||||
|
deep: 위 2종 + Confidence (curate_result 필요)
|
||||||
|
"""
|
||||||
|
results: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
# --- Sim Consensus ---
|
||||||
|
try:
|
||||||
|
best = await _fetch_best_picks()
|
||||||
|
v = signals.sim_consensus_score(best)
|
||||||
|
results.append(
|
||||||
|
evaluate_metric_and_persist(
|
||||||
|
source=source, metric="sim_signal",
|
||||||
|
value=v, draw_no=None,
|
||||||
|
z_normal=z_normal, z_urgent=z_urgent,
|
||||||
|
push_to_window=True,
|
||||||
|
payload={"top_count": min(len(best), 10)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"sim_consensus 평가 실패: {e}")
|
||||||
|
|
||||||
|
# --- Strategy Drift (회차 단위) ---
|
||||||
|
try:
|
||||||
|
w_curr = await _fetch_strategy_weights()
|
||||||
|
# weights 캐시: lotto_baselines의 별도 metric 'drift_weights_cache'에 prev/curr 2개 보관
|
||||||
|
prev_payload_row = db.get_baseline("drift_weights_cache")
|
||||||
|
w_prev = prev_payload_row["window_values"] if prev_payload_row else None
|
||||||
|
|
||||||
|
if w_prev and isinstance(w_prev, list) and len(w_prev) > 0 and isinstance(w_prev[0], dict):
|
||||||
|
prev_dict = w_prev[-1]
|
||||||
|
drift_value = signals.strategy_drift_score(prev_dict, w_curr)
|
||||||
|
results.append(
|
||||||
|
evaluate_metric_and_persist(
|
||||||
|
source=source, metric="drift",
|
||||||
|
value=drift_value, draw_no=current_draw_no,
|
||||||
|
z_normal=z_normal, z_urgent=z_urgent,
|
||||||
|
push_to_window=True,
|
||||||
|
payload={"weights_now": w_curr, "weights_prev": prev_dict},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# weights 캐시 갱신 (최대 2개 FIFO)
|
||||||
|
cache_window = (w_prev or []) + [w_curr]
|
||||||
|
if len(cache_window) > 2:
|
||||||
|
cache_window = cache_window[-2:]
|
||||||
|
db.upsert_baseline(
|
||||||
|
metric="drift_weights_cache",
|
||||||
|
window_values=cache_window,
|
||||||
|
mu=0.0, sigma=0.0,
|
||||||
|
last_pushed_draw_no=current_draw_no,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"strategy_drift 평가 실패: {e}")
|
||||||
|
|
||||||
|
# --- Confidence (deep_check + curate_result 필수) ---
|
||||||
|
if source == "deep" and curate_result is not None:
|
||||||
|
try:
|
||||||
|
cv = signals.confidence_score(curate_result)
|
||||||
|
if cv is not None:
|
||||||
|
results.append(
|
||||||
|
evaluate_metric_and_persist(
|
||||||
|
source=source, metric="confidence",
|
||||||
|
value=cv, draw_no=current_draw_no,
|
||||||
|
z_normal=z_normal, z_urgent=z_urgent,
|
||||||
|
push_to_window=True,
|
||||||
|
payload={"draw_no": current_draw_no},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"confidence 평가 실패: {e}")
|
||||||
|
|
||||||
|
overall = signals.decide_overall_fire(
|
||||||
|
[{"metric": r["metric"], "z": r["z_score"], "fire": r["fire_level"]} for r in results]
|
||||||
|
)
|
||||||
|
return {"overall_fire": overall, "results": results}
|
||||||
150
agent-office/app/curator/signals.py
Normal file
150
agent-office/app/curator/signals.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# agent-office/app/curator/signals.py
|
||||||
|
"""LottoAgent 능동 모니터링 — 시그널 평가 & adaptive baseline (순수 함수).
|
||||||
|
|
||||||
|
DB I/O 없음. 입력은 모두 dict/list, 출력도 dict/list.
|
||||||
|
signal_runner.py에서 DB 연동 + cron 진입점 담당.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from statistics import mean, stdev
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Metric: Sim Consensus ----------
|
||||||
|
|
||||||
|
def _normalize_columns(picks: List[Dict[str, Any]]) -> List[List[float]]:
|
||||||
|
"""20개 후보의 5종 점수 컬럼별 min-max normalize → 후보별 5종 정규화 점수."""
|
||||||
|
if not picks:
|
||||||
|
return []
|
||||||
|
n_metrics = len(picks[0]["scores"])
|
||||||
|
columns = [[p["scores"][k] for p in picks] for k in range(n_metrics)]
|
||||||
|
norms_per_col = []
|
||||||
|
for col in columns:
|
||||||
|
lo, hi = min(col), max(col)
|
||||||
|
rng = hi - lo
|
||||||
|
if rng == 0:
|
||||||
|
# 모두 0이면 0.0(기하평균 페널티), 모두 동일한 양수면 0.5(타이 처리)
|
||||||
|
fallback = 0.0 if lo == 0 else 0.5
|
||||||
|
norms_per_col.append([fallback] * len(col))
|
||||||
|
else:
|
||||||
|
norms_per_col.append([(v - lo) / rng for v in col])
|
||||||
|
return [
|
||||||
|
[norms_per_col[k][i] for k in range(n_metrics)]
|
||||||
|
for i in range(len(picks))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _geomean(values: List[float]) -> float:
|
||||||
|
"""기하평균. 0이 하나라도 있으면 0 (한 차원이 0인 후보 강하게 페널티)."""
|
||||||
|
if not values:
|
||||||
|
return 0.0
|
||||||
|
if any(v <= 0 for v in values):
|
||||||
|
return 0.0
|
||||||
|
log_sum = sum(math.log(v) for v in values)
|
||||||
|
return math.exp(log_sum / len(values))
|
||||||
|
|
||||||
|
|
||||||
|
def sim_consensus_score(best_picks: List[Dict[str, Any]]) -> float:
|
||||||
|
"""top-10 후보의 기하평균 consensus 평균."""
|
||||||
|
if not best_picks:
|
||||||
|
return 0.0
|
||||||
|
normalized = _normalize_columns(best_picks)
|
||||||
|
consensus = [_geomean(scores) for scores in normalized]
|
||||||
|
consensus.sort(reverse=True)
|
||||||
|
top = consensus[:10] if len(consensus) >= 10 else consensus
|
||||||
|
return mean(top) if top else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Metric: Strategy Drift ----------
|
||||||
|
|
||||||
|
def strategy_drift_score(prev: Dict[str, float], curr: Dict[str, float]) -> float:
|
||||||
|
"""가중치 변화 절댓값 합. 신규/소멸 전략도 가산."""
|
||||||
|
keys = set(prev) | set(curr)
|
||||||
|
return sum(abs(curr.get(k, 0.0) - prev.get(k, 0.0)) for k in keys)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Metric: Confidence ----------
|
||||||
|
|
||||||
|
def confidence_score(curate_result: Dict[str, Any]) -> Optional[float]:
|
||||||
|
"""큐레이션 결과의 confidence를 0~1로 clamp. 없으면 None."""
|
||||||
|
if "confidence" not in curate_result:
|
||||||
|
return None
|
||||||
|
v = float(curate_result["confidence"])
|
||||||
|
return max(0.0, min(1.0, v))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Adaptive Baseline ----------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdaptiveBaseline:
|
||||||
|
window: List[float] = field(default_factory=list)
|
||||||
|
window_max: int = 8
|
||||||
|
last_pushed_draw_no: Optional[int] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> int:
|
||||||
|
return len(self.window)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mu(self) -> float:
|
||||||
|
return mean(self.window) if self.window else 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sigma(self) -> float:
|
||||||
|
return stdev(self.window) if len(self.window) >= 2 else 0.0
|
||||||
|
|
||||||
|
def push(self, value: float, draw_no: Optional[int] = None) -> None:
|
||||||
|
"""FIFO push. window_max 초과 시 가장 오래된 값 제거."""
|
||||||
|
self.window.append(float(value))
|
||||||
|
if len(self.window) > self.window_max:
|
||||||
|
self.window = self.window[-self.window_max:]
|
||||||
|
if draw_no is not None:
|
||||||
|
self.last_pushed_draw_no = draw_no
|
||||||
|
|
||||||
|
def evaluate(self, value: float, z_normal: float, z_urgent: float) -> Tuple[Optional[float], str]:
|
||||||
|
"""z-score 계산 + fire_level 판정.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(z_score, fire_level) — z_score는 cold start/warmup이면 None.
|
||||||
|
fire_level ∈ {'warmup', 'noop', 'normal', 'urgent'}
|
||||||
|
|
||||||
|
NOTE: z_score is None when sigma==0 (degenerate window) or warmup.
|
||||||
|
Callers must treat None as "signal present but unquantified" — do not
|
||||||
|
compare None with thresholds directly.
|
||||||
|
"""
|
||||||
|
if self.size < 4:
|
||||||
|
return None, "warmup"
|
||||||
|
|
||||||
|
z_normal_eff = 2.0 if self.size < self.window_max else z_normal
|
||||||
|
z_urgent_eff = z_urgent
|
||||||
|
|
||||||
|
if self.sigma == 0:
|
||||||
|
return (None, "urgent") if value > self.mu else (None, "noop")
|
||||||
|
|
||||||
|
z = (value - self.mu) / self.sigma
|
||||||
|
if z >= z_urgent_eff:
|
||||||
|
return z, "urgent"
|
||||||
|
if z >= z_normal_eff:
|
||||||
|
return z, "normal"
|
||||||
|
return z, "noop"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Combined fire decision ----------
|
||||||
|
|
||||||
|
def decide_overall_fire(signal_results: List[Dict[str, Any]]) -> str:
|
||||||
|
"""3종 시그널을 종합해 전체 fire_level 결정.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signal_results: [{"metric": str, "z": float|None, "fire": str}, ...]
|
||||||
|
Returns:
|
||||||
|
'noop' | 'normal' | 'urgent'
|
||||||
|
"""
|
||||||
|
fires = [s for s in signal_results if s["fire"] in ("normal", "urgent")]
|
||||||
|
if any(s["fire"] == "urgent" for s in fires):
|
||||||
|
return "urgent"
|
||||||
|
if len(fires) >= 2:
|
||||||
|
return "urgent"
|
||||||
|
if len(fires) == 1:
|
||||||
|
return "normal"
|
||||||
|
return "noop"
|
||||||
909
agent-office/app/db.py
Normal file
909
agent-office/app/db.py
Normal file
@@ -0,0 +1,909 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from .config import DB_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def _conn() -> sqlite3.Connection:
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=120000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_config (
|
||||||
|
agent_id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
schedule_config TEXT NOT NULL DEFAULT '{}',
|
||||||
|
custom_config TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
task_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
input_data TEXT NOT NULL DEFAULT '{}',
|
||||||
|
result_data TEXT,
|
||||||
|
requires_approval INTEGER NOT NULL DEFAULT 0,
|
||||||
|
approved_at TEXT,
|
||||||
|
approved_via TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
completed_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_agent
|
||||||
|
ON agent_tasks(agent_id, created_at DESC)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
task_id TEXT,
|
||||||
|
level TEXT NOT NULL DEFAULT 'info',
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS telegram_state (
|
||||||
|
callback_id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
action TEXT,
|
||||||
|
responded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
chat_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
model TEXT,
|
||||||
|
tokens_input INTEGER DEFAULT 0,
|
||||||
|
tokens_output INTEGER DEFAULT 0,
|
||||||
|
cache_read INTEGER DEFAULT 0,
|
||||||
|
cache_write INTEGER DEFAULT 0,
|
||||||
|
latency_ms INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conv_chat
|
||||||
|
ON conversation_messages(chat_id, created_at DESC)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS youtube_research_jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running',
|
||||||
|
countries TEXT NOT NULL DEFAULT '[]',
|
||||||
|
trends_collected INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
completed_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS lotto_signals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
triggered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
metric TEXT NOT NULL,
|
||||||
|
value REAL NOT NULL,
|
||||||
|
baseline_mu REAL,
|
||||||
|
baseline_sigma REAL,
|
||||||
|
z_score REAL,
|
||||||
|
fire_level TEXT NOT NULL,
|
||||||
|
notified_at TEXT,
|
||||||
|
payload TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ls_triggered
|
||||||
|
ON lotto_signals(triggered_at DESC)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ls_fire
|
||||||
|
ON lotto_signals(fire_level, notified_at)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS lotto_baselines (
|
||||||
|
metric TEXT PRIMARY KEY,
|
||||||
|
window_values TEXT NOT NULL DEFAULT '[]',
|
||||||
|
mu REAL NOT NULL DEFAULT 0.0,
|
||||||
|
sigma REAL NOT NULL DEFAULT 0.0,
|
||||||
|
last_pushed_draw_no INTEGER,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
spread_type TEXT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
question TEXT,
|
||||||
|
cards TEXT NOT NULL,
|
||||||
|
interpretation_json TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
model TEXT,
|
||||||
|
tokens_in INTEGER,
|
||||||
|
tokens_out INTEGER,
|
||||||
|
cost_usd REAL,
|
||||||
|
confidence TEXT,
|
||||||
|
favorite INTEGER NOT NULL DEFAULT 0,
|
||||||
|
note TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tarot_created
|
||||||
|
ON tarot_readings(created_at DESC)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tarot_favorite
|
||||||
|
ON tarot_readings(favorite, created_at DESC)
|
||||||
|
""")
|
||||||
|
# Seed default agent configs
|
||||||
|
for agent_id, name in [
|
||||||
|
("stock", "주식 트레이더"),
|
||||||
|
("music", "음악 프로듀서"),
|
||||||
|
("blog", "블로그 마케터"),
|
||||||
|
("realestate", "청약 애널리스트"),
|
||||||
|
("lotto", "로또 큐레이터"),
|
||||||
|
("youtube", "YouTube 리서치"),
|
||||||
|
]:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO agent_config(agent_id, display_name) VALUES(?,?)",
|
||||||
|
(agent_id, name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- agent_config CRUD ---
|
||||||
|
|
||||||
|
def get_all_agents() -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM agent_config ORDER BY agent_id").fetchall()
|
||||||
|
return [_config_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_config(agent_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
r = conn.execute("SELECT * FROM agent_config WHERE agent_id=?", (agent_id,)).fetchone()
|
||||||
|
return _config_to_dict(r) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def update_agent_config(agent_id: str, **kwargs) -> None:
|
||||||
|
sets, vals = [], []
|
||||||
|
for k in ("enabled", "schedule_config", "custom_config"):
|
||||||
|
if k in kwargs and kwargs[k] is not None:
|
||||||
|
if k in ("schedule_config", "custom_config"):
|
||||||
|
sets.append(f"{k}=?")
|
||||||
|
vals.append(json.dumps(kwargs[k]))
|
||||||
|
else:
|
||||||
|
sets.append(f"{k}=?")
|
||||||
|
vals.append(kwargs[k])
|
||||||
|
if not sets:
|
||||||
|
return
|
||||||
|
sets.append("updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')")
|
||||||
|
vals.append(agent_id)
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(f"UPDATE agent_config SET {','.join(sets)} WHERE agent_id=?", vals)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_to_dict(r) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"agent_id": r["agent_id"],
|
||||||
|
"display_name": r["display_name"],
|
||||||
|
"enabled": bool(r["enabled"]),
|
||||||
|
"schedule_config": json.loads(r["schedule_config"]),
|
||||||
|
"custom_config": json.loads(r["custom_config"]),
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"updated_at": r["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- agent_tasks CRUD ---
|
||||||
|
|
||||||
|
def create_task(agent_id: str, task_type: str, input_data: dict, requires_approval: bool = False) -> str:
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
status = "pending" if requires_approval else "working"
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO agent_tasks(id,agent_id,task_type,status,input_data,requires_approval) VALUES(?,?,?,?,?,?)",
|
||||||
|
(task_id, agent_id, task_type, status, json.dumps(input_data), int(requires_approval)),
|
||||||
|
)
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
def update_task_status(task_id: str, status: str, result_data: dict = None) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
if result_data is not None:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE agent_tasks SET status=?, result_data=?, completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
|
||||||
|
(status, json.dumps(result_data), task_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute("UPDATE agent_tasks SET status=? WHERE id=?", (status, task_id))
|
||||||
|
|
||||||
|
|
||||||
|
def approve_task(task_id: str, via: str = "web") -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE agent_tasks SET status='approved', approved_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), approved_via=? WHERE id=?",
|
||||||
|
(via, task_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reject_task(task_id: str) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE agent_tasks SET status='rejected', completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
|
||||||
|
(task_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_task(task_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
r = conn.execute("SELECT * FROM agent_tasks WHERE id=?", (task_id,)).fetchone()
|
||||||
|
return _task_to_dict(r) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_tasks(
|
||||||
|
agent_id: str,
|
||||||
|
limit: int = 20,
|
||||||
|
task_type: Optional[str] = None,
|
||||||
|
days: Optional[int] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
sql = "SELECT * FROM agent_tasks WHERE agent_id=?"
|
||||||
|
params: List[Any] = [agent_id]
|
||||||
|
if task_type is not None:
|
||||||
|
sql += " AND task_type=?"
|
||||||
|
params.append(task_type)
|
||||||
|
if days is not None and days > 0:
|
||||||
|
sql += " AND created_at >= datetime('now', ?)"
|
||||||
|
params.append(f"-{int(days)} days")
|
||||||
|
sql += " ORDER BY created_at DESC LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(sql, params).fetchall()
|
||||||
|
return [_task_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_pending_approvals() -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM agent_tasks WHERE status='pending' AND requires_approval=1 ORDER BY created_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [_task_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _task_to_dict(r) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": r["id"],
|
||||||
|
"agent_id": r["agent_id"],
|
||||||
|
"task_type": r["task_type"],
|
||||||
|
"status": r["status"],
|
||||||
|
"input_data": json.loads(r["input_data"]) if r["input_data"] else {},
|
||||||
|
"result_data": json.loads(r["result_data"]) if r["result_data"] else None,
|
||||||
|
"requires_approval": bool(r["requires_approval"]),
|
||||||
|
"approved_at": r["approved_at"],
|
||||||
|
"approved_via": r["approved_via"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"completed_at": r["completed_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- agent_logs ---
|
||||||
|
|
||||||
|
def add_log(agent_id: str, message: str, level: str = "info", task_id: str = None) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO agent_logs(agent_id,task_id,level,message) VALUES(?,?,?,?)",
|
||||||
|
(agent_id, task_id, level, message),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM agent_logs WHERE agent_id=? ORDER BY created_at DESC LIMIT ?",
|
||||||
|
(agent_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"agent_id": r["agent_id"],
|
||||||
|
"task_id": r["task_id"],
|
||||||
|
"level": r["level"],
|
||||||
|
"message": r["message"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- telegram_state ---
|
||||||
|
|
||||||
|
def save_telegram_callback(callback_id: str, task_id: str, agent_id: str) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO telegram_state(callback_id,task_id,agent_id) VALUES(?,?,?)",
|
||||||
|
(callback_id, task_id, agent_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_telegram_callback(callback_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT * FROM telegram_state WHERE callback_id=? AND responded=0",
|
||||||
|
(callback_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not r:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"callback_id": r["callback_id"],
|
||||||
|
"task_id": r["task_id"],
|
||||||
|
"agent_id": r["agent_id"],
|
||||||
|
"responded": bool(r["responded"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mark_telegram_responded(callback_id: str, action: str) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE telegram_state SET responded=1, action=? WHERE callback_id=?",
|
||||||
|
(action, callback_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_usage_stats(agent_id: str, days: int = 1) -> dict:
|
||||||
|
"""지정 에이전트의 최근 N일 토큰 사용량 집계.
|
||||||
|
|
||||||
|
agent_tasks 테이블의 result_data JSON에서 tokens.total을 합산.
|
||||||
|
반환: {"total_tokens": int, "task_count": int, "by_day": [{"date": "YYYY-MM-DD", "tokens": int}]}
|
||||||
|
"""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT completed_at, result_data
|
||||||
|
FROM agent_tasks
|
||||||
|
WHERE agent_id = ?
|
||||||
|
AND status = 'succeeded'
|
||||||
|
AND completed_at IS NOT NULL
|
||||||
|
AND completed_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?)
|
||||||
|
""",
|
||||||
|
(agent_id, f"-{int(days)} days"),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
total_tokens = 0
|
||||||
|
task_count = 0
|
||||||
|
by_day_map: Dict[str, int] = {}
|
||||||
|
for r in rows:
|
||||||
|
result_data = r["result_data"]
|
||||||
|
if not result_data:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(result_data)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
tokens = parsed.get("tokens") if isinstance(parsed, dict) else None
|
||||||
|
total = 0
|
||||||
|
if isinstance(tokens, dict):
|
||||||
|
total = int(tokens.get("total", 0) or 0)
|
||||||
|
if total <= 0:
|
||||||
|
continue
|
||||||
|
total_tokens += total
|
||||||
|
task_count += 1
|
||||||
|
completed_at = r["completed_at"] or ""
|
||||||
|
day = completed_at[:10] if completed_at else "unknown"
|
||||||
|
by_day_map[day] = by_day_map.get(day, 0) + total
|
||||||
|
|
||||||
|
by_day = [
|
||||||
|
{"date": d, "tokens": t}
|
||||||
|
for d, t in sorted(by_day_map.items())
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"total_tokens": total_tokens,
|
||||||
|
"task_count": task_count,
|
||||||
|
"by_day": by_day,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_conversation_message(
|
||||||
|
chat_id: str,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
tokens_input: int = 0,
|
||||||
|
tokens_output: int = 0,
|
||||||
|
cache_read: int = 0,
|
||||||
|
cache_write: int = 0,
|
||||||
|
latency_ms: int = 0,
|
||||||
|
) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO conversation_messages
|
||||||
|
(chat_id, role, content, model, tokens_input, tokens_output,
|
||||||
|
cache_read, cache_write, latency_ms)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)
|
||||||
|
""",
|
||||||
|
(str(chat_id), role, content, model, tokens_input, tokens_output,
|
||||||
|
cache_read, cache_write, latency_ms),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation_history(chat_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""최근 N개를 시간순(오래된 → 최신)으로 반환."""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT role, content FROM conversation_messages
|
||||||
|
WHERE chat_id=? ORDER BY id DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
(str(chat_id), limit),
|
||||||
|
).fetchall()
|
||||||
|
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
|
||||||
|
|
||||||
|
|
||||||
|
def count_recent_user_messages(chat_id: str, seconds: int = 60) -> int:
|
||||||
|
with _conn() as conn:
|
||||||
|
r = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS c FROM conversation_messages
|
||||||
|
WHERE chat_id=? AND role='user'
|
||||||
|
AND created_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?)
|
||||||
|
""",
|
||||||
|
(str(chat_id), f"-{int(seconds)} seconds"),
|
||||||
|
).fetchone()
|
||||||
|
return r["c"] if r else 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversation_stats(days: int = 7) -> Dict[str, Any]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT chat_id,
|
||||||
|
COUNT(*) AS msg_count,
|
||||||
|
SUM(tokens_input) AS in_tokens,
|
||||||
|
SUM(tokens_output) AS out_tokens,
|
||||||
|
SUM(cache_read) AS cache_read,
|
||||||
|
SUM(cache_write) AS cache_write,
|
||||||
|
AVG(latency_ms) AS avg_latency
|
||||||
|
FROM conversation_messages
|
||||||
|
WHERE role='assistant'
|
||||||
|
AND created_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?)
|
||||||
|
GROUP BY chat_id
|
||||||
|
""",
|
||||||
|
(f"-{int(days)} days",),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
by_chat = []
|
||||||
|
tot_in = tot_out = tot_r = tot_w = tot_msgs = 0
|
||||||
|
for r in rows:
|
||||||
|
ci = int(r["in_tokens"] or 0)
|
||||||
|
co = int(r["out_tokens"] or 0)
|
||||||
|
cr = int(r["cache_read"] or 0)
|
||||||
|
cw = int(r["cache_write"] or 0)
|
||||||
|
mc = int(r["msg_count"] or 0)
|
||||||
|
hit_rate = (cr / (cr + cw)) if (cr + cw) > 0 else 0.0
|
||||||
|
by_chat.append({
|
||||||
|
"chat_id": r["chat_id"],
|
||||||
|
"message_count": mc,
|
||||||
|
"tokens_input": ci,
|
||||||
|
"tokens_output": co,
|
||||||
|
"cache_read": cr,
|
||||||
|
"cache_write": cw,
|
||||||
|
"cache_hit_rate": round(hit_rate, 3),
|
||||||
|
"avg_latency_ms": round(float(r["avg_latency"] or 0), 1),
|
||||||
|
})
|
||||||
|
tot_in += ci; tot_out += co; tot_r += cr; tot_w += cw; tot_msgs += mc
|
||||||
|
|
||||||
|
overall_hit = (tot_r / (tot_r + tot_w)) if (tot_r + tot_w) > 0 else 0.0
|
||||||
|
return {
|
||||||
|
"days": days,
|
||||||
|
"total_messages": tot_msgs,
|
||||||
|
"tokens_input": tot_in,
|
||||||
|
"tokens_output": tot_out,
|
||||||
|
"cache_read": tot_r,
|
||||||
|
"cache_write": tot_w,
|
||||||
|
"cache_hit_rate": round(overall_hit, 3),
|
||||||
|
"by_chat": by_chat,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_feed(limit: int = 50, offset: int = 0) -> dict:
|
||||||
|
with _conn() as conn:
|
||||||
|
total_row = conn.execute("""
|
||||||
|
SELECT (SELECT COUNT(*) FROM agent_tasks) + (SELECT COUNT(*) FROM agent_logs) AS total
|
||||||
|
""").fetchone()
|
||||||
|
total = total_row["total"] if total_row else 0
|
||||||
|
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT 'task' AS type, agent_id, id AS task_id, task_type,
|
||||||
|
status, NULL AS level,
|
||||||
|
COALESCE(
|
||||||
|
json_extract(result_data, '$.summary'),
|
||||||
|
task_type
|
||||||
|
) AS message,
|
||||||
|
created_at, completed_at,
|
||||||
|
result_data
|
||||||
|
FROM agent_tasks
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'log' AS type, agent_id, task_id, NULL AS task_type,
|
||||||
|
NULL AS status, level,
|
||||||
|
message,
|
||||||
|
created_at, NULL AS completed_at,
|
||||||
|
NULL AS result_data
|
||||||
|
FROM agent_logs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""", (limit, offset)).fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
item = {
|
||||||
|
"type": r["type"],
|
||||||
|
"agent_id": r["agent_id"],
|
||||||
|
"task_id": r["task_id"],
|
||||||
|
"message": r["message"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
}
|
||||||
|
if r["type"] == "task":
|
||||||
|
item["task_type"] = r["task_type"]
|
||||||
|
item["status"] = r["status"]
|
||||||
|
item["completed_at"] = r["completed_at"]
|
||||||
|
if r["created_at"] and r["completed_at"]:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
start = datetime.fromisoformat(r["created_at"].replace("Z", "+00:00"))
|
||||||
|
end = datetime.fromisoformat(r["completed_at"].replace("Z", "+00:00"))
|
||||||
|
item["duration_seconds"] = round((end - start).total_seconds())
|
||||||
|
except Exception:
|
||||||
|
item["duration_seconds"] = None
|
||||||
|
else:
|
||||||
|
item["duration_seconds"] = None
|
||||||
|
result_data = json.loads(r["result_data"]) if r["result_data"] else None
|
||||||
|
if result_data and "telegram_sent" in result_data:
|
||||||
|
item["telegram_sent"] = result_data["telegram_sent"]
|
||||||
|
else:
|
||||||
|
item["level"] = r["level"]
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return {"items": items, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
# ── youtube_research_jobs CRUD ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_youtube_research_job(countries: list) -> int:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO youtube_research_jobs (countries) VALUES (?)",
|
||||||
|
(json.dumps(countries),),
|
||||||
|
)
|
||||||
|
return conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def update_youtube_research_job(
|
||||||
|
job_id: int, status: str, trends_collected: int, error: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE youtube_research_jobs
|
||||||
|
SET status=?, trends_collected=?, error=?,
|
||||||
|
completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||||
|
WHERE id=?""",
|
||||||
|
(status, trends_collected, error, job_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_youtube_research_job() -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM youtube_research_jobs ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"status": row["status"],
|
||||||
|
"countries": json.loads(row["countries"]),
|
||||||
|
"trends_collected": row["trends_collected"],
|
||||||
|
"error": row["error"],
|
||||||
|
"started_at": row["started_at"],
|
||||||
|
"completed_at": row["completed_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- lotto_signals / lotto_baselines CRUD ---
|
||||||
|
|
||||||
|
def insert_lotto_signal(
|
||||||
|
source: str,
|
||||||
|
metric: str,
|
||||||
|
value: float,
|
||||||
|
baseline_mu: Optional[float],
|
||||||
|
baseline_sigma: Optional[float],
|
||||||
|
z_score: Optional[float],
|
||||||
|
fire_level: str,
|
||||||
|
payload: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> int:
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO lotto_signals
|
||||||
|
(source, metric, value, baseline_mu, baseline_sigma, z_score, fire_level, payload)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
source, metric, value,
|
||||||
|
baseline_mu, baseline_sigma, z_score, fire_level,
|
||||||
|
json.dumps(payload or {}, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def mark_signal_notified(signal_id: int) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE lotto_signals SET notified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
|
||||||
|
(signal_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_lotto_signals(hours: int = 24, min_fire: str = "normal") -> List[Dict[str, Any]]:
|
||||||
|
"""지난 N시간 발화 시그널. min_fire='normal'이면 normal+urgent."""
|
||||||
|
levels = ("urgent",) if min_fire == "urgent" else ("normal", "urgent")
|
||||||
|
placeholders = ",".join("?" * len(levels))
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM lotto_signals
|
||||||
|
WHERE triggered_at >= datetime('now', ?)
|
||||||
|
AND fire_level IN ({placeholders})
|
||||||
|
ORDER BY triggered_at DESC
|
||||||
|
""",
|
||||||
|
(f"-{int(hours)} hours", *levels),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_signals_history(days: int = 7) -> List[Dict[str, Any]]:
|
||||||
|
"""차트/이력 페이지용 — 모든 fire_level 포함."""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM lotto_signals
|
||||||
|
WHERE triggered_at >= datetime('now', ?)
|
||||||
|
ORDER BY triggered_at DESC
|
||||||
|
""",
|
||||||
|
(f"-{int(days)} days",),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_urgent_count(hours: int = 24) -> int:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS c FROM lotto_signals
|
||||||
|
WHERE triggered_at >= datetime('now', ?)
|
||||||
|
AND fire_level = 'urgent'
|
||||||
|
AND notified_at IS NOT NULL
|
||||||
|
""",
|
||||||
|
(f"-{int(hours)} hours",),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["c"]) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_signal_notification(metric: str, fire_level: str, hours: int) -> Optional[str]:
|
||||||
|
"""같은 metric+fire_level이 hours 내에 알림 발송된 마지막 시각. throttle용."""
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT notified_at FROM lotto_signals
|
||||||
|
WHERE metric = ?
|
||||||
|
AND fire_level = ?
|
||||||
|
AND notified_at IS NOT NULL
|
||||||
|
AND notified_at >= datetime('now', ?)
|
||||||
|
ORDER BY notified_at DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(metric, fire_level, f"-{int(hours)} hours"),
|
||||||
|
).fetchone()
|
||||||
|
return row["notified_at"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_baseline(metric: str) -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM lotto_baselines WHERE metric = ?",
|
||||||
|
(metric,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
d["window_values"] = json.loads(d["window_values"])
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_baseline(
|
||||||
|
metric: str,
|
||||||
|
window_values: List[float],
|
||||||
|
mu: float,
|
||||||
|
sigma: float,
|
||||||
|
last_pushed_draw_no: Optional[int],
|
||||||
|
) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO lotto_baselines
|
||||||
|
(metric, window_values, mu, sigma, last_pushed_draw_no, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
ON CONFLICT(metric) DO UPDATE SET
|
||||||
|
window_values = excluded.window_values,
|
||||||
|
mu = excluded.mu,
|
||||||
|
sigma = excluded.sigma,
|
||||||
|
last_pushed_draw_no = excluded.last_pushed_draw_no,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
metric,
|
||||||
|
json.dumps(window_values),
|
||||||
|
mu, sigma, last_pushed_draw_no,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_baselines() -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM lotto_baselines ORDER BY metric").fetchall()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["window_values"] = json.loads(d["window_values"])
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_tasks_by_agent_date_kind(agent_id: str, date_iso: str, task_type: str) -> List[Dict[str, Any]]:
|
||||||
|
"""같은 (agent, date, task_type)으로 이미 생성된 task 조회. 멱등 guard."""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM agent_tasks
|
||||||
|
WHERE agent_id = ? AND task_type = ?
|
||||||
|
AND substr(created_at, 1, 10) = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""",
|
||||||
|
(agent_id, task_type, date_iso),
|
||||||
|
).fetchall()
|
||||||
|
return [_task_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# --- tarot_readings CRUD ---
|
||||||
|
|
||||||
|
def save_tarot_reading(data: Dict[str, Any]) -> int:
|
||||||
|
interp = data.get("interpretation_json") or {}
|
||||||
|
summary = interp.get("summary", "") if isinstance(interp, dict) else ""
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO tarot_readings
|
||||||
|
(spread_type, category, question, cards, interpretation_json,
|
||||||
|
summary, model, tokens_in, tokens_out, cost_usd, confidence)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
data["spread_type"],
|
||||||
|
data.get("category"),
|
||||||
|
data.get("question"),
|
||||||
|
json.dumps(data.get("cards") or [], ensure_ascii=False),
|
||||||
|
json.dumps(interp, ensure_ascii=False) if interp else None,
|
||||||
|
summary,
|
||||||
|
data.get("model"),
|
||||||
|
data.get("tokens_in"),
|
||||||
|
data.get("tokens_out"),
|
||||||
|
data.get("cost_usd"),
|
||||||
|
data.get("confidence"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tarot_reading(reading_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
r = conn.execute("SELECT * FROM tarot_readings WHERE id=?", (reading_id,)).fetchone()
|
||||||
|
return _tarot_row_to_dict(r) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_tarot_readings(
|
||||||
|
page: int = 1, size: int = 20,
|
||||||
|
favorite: Optional[bool] = None,
|
||||||
|
spread_type: Optional[str] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
wheres, params = [], []
|
||||||
|
if favorite is not None:
|
||||||
|
wheres.append("favorite=?")
|
||||||
|
params.append(1 if favorite else 0)
|
||||||
|
if spread_type:
|
||||||
|
wheres.append("spread_type=?")
|
||||||
|
params.append(spread_type)
|
||||||
|
if category:
|
||||||
|
wheres.append("category=?")
|
||||||
|
params.append(category)
|
||||||
|
where_sql = ("WHERE " + " AND ".join(wheres)) if wheres else ""
|
||||||
|
offset = (page - 1) * size
|
||||||
|
with _conn() as conn:
|
||||||
|
total = conn.execute(
|
||||||
|
f"SELECT COUNT(*) c FROM tarot_readings {where_sql}", params
|
||||||
|
).fetchone()["c"]
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM tarot_readings {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||||
|
params + [size, offset],
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"items": [_tarot_row_to_dict(r) for r in rows],
|
||||||
|
"page": page, "size": size, "total": int(total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_tarot_reading(reading_id: int, **kwargs) -> None:
|
||||||
|
sets, vals = [], []
|
||||||
|
if "favorite" in kwargs and kwargs["favorite"] is not None:
|
||||||
|
sets.append("favorite=?")
|
||||||
|
vals.append(1 if kwargs["favorite"] else 0)
|
||||||
|
if "note" in kwargs and kwargs["note"] is not None:
|
||||||
|
sets.append("note=?")
|
||||||
|
vals.append(kwargs["note"])
|
||||||
|
if not sets:
|
||||||
|
return
|
||||||
|
vals.append(reading_id)
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(f"UPDATE tarot_readings SET {','.join(sets)} WHERE id=?", vals)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_tarot_reading(reading_id: int) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute("DELETE FROM tarot_readings WHERE id=?", (reading_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def _tarot_row_to_dict(r) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
interp = json.loads(r["interpretation_json"]) if r["interpretation_json"] else None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
interp = None
|
||||||
|
try:
|
||||||
|
cards = json.loads(r["cards"]) if r["cards"] else []
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
cards = []
|
||||||
|
return {
|
||||||
|
"id": r["id"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"spread_type": r["spread_type"],
|
||||||
|
"category": r["category"],
|
||||||
|
"question": r["question"],
|
||||||
|
"cards": cards,
|
||||||
|
"interpretation_json": interp,
|
||||||
|
"summary": r["summary"],
|
||||||
|
"model": r["model"],
|
||||||
|
"tokens_in": r["tokens_in"],
|
||||||
|
"tokens_out": r["tokens_out"],
|
||||||
|
"cost_usd": r["cost_usd"],
|
||||||
|
"confidence": r["confidence"],
|
||||||
|
"favorite": int(r["favorite"]),
|
||||||
|
"note": r["note"],
|
||||||
|
}
|
||||||
266
agent-office/app/main.py
Normal file
266
agent-office/app/main.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from .config import CORS_ALLOW_ORIGINS
|
||||||
|
from .db import init_db, get_all_agents, get_agent_config, update_agent_config, get_agent_tasks, get_pending_approvals, get_task, get_logs, get_activity_feed, get_latest_youtube_research_job
|
||||||
|
from .models import CommandRequest, ApprovalRequest, AgentConfigUpdate
|
||||||
|
from .websocket_manager import ws_manager
|
||||||
|
from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY
|
||||||
|
from .scheduler import init_scheduler
|
||||||
|
from . import telegram_bot
|
||||||
|
from .routers import notify as notify_router
|
||||||
|
from .routers import tarot as tarot_router
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(notify_router.router)
|
||||||
|
app.include_router(tarot_router.router)
|
||||||
|
|
||||||
|
_cors_origins = CORS_ALLOW_ORIGINS.split(",")
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[o.strip() for o in _cors_origins],
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=["Content-Type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def on_startup():
|
||||||
|
init_db()
|
||||||
|
os.makedirs("/app/data", exist_ok=True)
|
||||||
|
init_agents()
|
||||||
|
for agent in AGENT_REGISTRY.values():
|
||||||
|
agent.set_ws_manager(ws_manager)
|
||||||
|
init_scheduler()
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# --- WebSocket ---
|
||||||
|
|
||||||
|
@app.websocket("/api/agent-office/ws")
|
||||||
|
async def websocket_endpoint(ws: WebSocket):
|
||||||
|
await ws_manager.connect(ws)
|
||||||
|
try:
|
||||||
|
await ws.send_text(json.dumps({
|
||||||
|
"type": "init",
|
||||||
|
"agents": get_all_agent_states(),
|
||||||
|
"pending": [t["id"] for t in get_pending_approvals()],
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
while True:
|
||||||
|
data = await ws.receive_text()
|
||||||
|
try:
|
||||||
|
msg = json.loads(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
await _handle_ws_message(msg)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await ws_manager.disconnect(ws)
|
||||||
|
|
||||||
|
async def _handle_ws_message(msg: dict):
|
||||||
|
msg_type = msg.get("type")
|
||||||
|
agent_id = msg.get("agent")
|
||||||
|
agent = get_agent(agent_id) if agent_id else None
|
||||||
|
|
||||||
|
if msg_type == "command" and agent:
|
||||||
|
action = msg.get("action", "")
|
||||||
|
params = msg.get("params", {})
|
||||||
|
result = await agent.on_command(action, params)
|
||||||
|
await ws_manager.broadcast({"type": "command_result", "agent": agent_id, "result": result})
|
||||||
|
|
||||||
|
elif msg_type == "approval" and agent:
|
||||||
|
task_id = msg.get("task_id")
|
||||||
|
approved = msg.get("approved", False)
|
||||||
|
if task_id:
|
||||||
|
await agent.on_approval(task_id, approved)
|
||||||
|
|
||||||
|
elif msg_type == "query" and agent:
|
||||||
|
status = await agent.get_status()
|
||||||
|
await ws_manager.broadcast({"type": "agent_status", "agent": agent_id, "status": status})
|
||||||
|
|
||||||
|
# --- REST Endpoints ---
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/agents")
|
||||||
|
def list_agents():
|
||||||
|
return {"agents": get_all_agents()}
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/agents/{agent_id}")
|
||||||
|
def agent_detail(agent_id: str):
|
||||||
|
config = get_agent_config(agent_id)
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent not found")
|
||||||
|
agent = get_agent(agent_id)
|
||||||
|
state_info = {"state": agent.state, "detail": agent.state_detail} if agent else {}
|
||||||
|
return {**config, **state_info}
|
||||||
|
|
||||||
|
@app.put("/api/agent-office/agents/{agent_id}")
|
||||||
|
def update_agent(agent_id: str, body: AgentConfigUpdate):
|
||||||
|
update_agent_config(agent_id, enabled=body.enabled,
|
||||||
|
schedule_config=body.schedule_config,
|
||||||
|
custom_config=body.custom_config)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/agents/{agent_id}/tasks")
|
||||||
|
def agent_tasks(
|
||||||
|
agent_id: str,
|
||||||
|
limit: int = 20,
|
||||||
|
task_type: Optional[str] = None,
|
||||||
|
days: Optional[int] = None,
|
||||||
|
):
|
||||||
|
tasks_list = get_agent_tasks(agent_id, limit=limit, task_type=task_type, days=days)
|
||||||
|
# Backward compat: 기존 client는 'tasks', 신규 client는 'items' 사용
|
||||||
|
return {"tasks": tasks_list, "items": tasks_list}
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/agents/{agent_id}/logs")
|
||||||
|
def agent_logs(agent_id: str, limit: int = 50):
|
||||||
|
return {"logs": get_logs(agent_id, limit)}
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/tasks/pending")
|
||||||
|
def pending_tasks():
|
||||||
|
return {"tasks": get_pending_approvals()}
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/tasks/{task_id}")
|
||||||
|
def task_detail(task_id: str):
|
||||||
|
task = get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
return task
|
||||||
|
|
||||||
|
@app.post("/api/agent-office/command")
|
||||||
|
async def send_command(body: CommandRequest):
|
||||||
|
agent = get_agent(body.agent)
|
||||||
|
if not agent:
|
||||||
|
return {"error": f"Agent '{body.agent}' not found"}
|
||||||
|
result = await agent.on_command(body.action, body.params or {})
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/api/agent-office/approve")
|
||||||
|
async def approve(body: ApprovalRequest):
|
||||||
|
agent = get_agent(body.agent)
|
||||||
|
if not agent:
|
||||||
|
return {"error": f"Agent '{body.agent}' not found"}
|
||||||
|
await agent.on_approval(body.task_id, body.approved, body.feedback or "")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# --- Telegram Webhook ---
|
||||||
|
|
||||||
|
async def _agent_dispatcher(agent_id: str, command: str, params: dict) -> dict:
|
||||||
|
"""텔레그램 라우터가 호출하는 에이전트 디스패처."""
|
||||||
|
# 전역 상태 조회
|
||||||
|
if agent_id == "__global__" and command == "status":
|
||||||
|
result = {}
|
||||||
|
for aid, agent in AGENT_REGISTRY.items():
|
||||||
|
result[aid] = {"state": agent.state, "detail": agent.state_detail}
|
||||||
|
return result
|
||||||
|
|
||||||
|
agent = AGENT_REGISTRY.get(agent_id)
|
||||||
|
if agent is None:
|
||||||
|
return {"ok": False, "message": f"Unknown agent: {agent_id}"}
|
||||||
|
return await agent.on_command(command, params or {})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/agent-office/telegram/webhook")
|
||||||
|
async def telegram_webhook(data: dict):
|
||||||
|
result = await telegram_bot.handle_webhook(data, agent_dispatcher=_agent_dispatcher)
|
||||||
|
# callback_query (승인/거절) → 기존 승인 흐름
|
||||||
|
if result and "approved" in result:
|
||||||
|
agent = get_agent(result["agent_id"])
|
||||||
|
if agent:
|
||||||
|
await agent.on_approval(result["task_id"], result["approved"])
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/states")
|
||||||
|
def all_states():
|
||||||
|
return {"agents": get_all_agent_states()}
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/agents/{agent_id}/token-usage")
|
||||||
|
def agent_token_usage(agent_id: str, days: int = 1):
|
||||||
|
from .db import get_token_usage_stats
|
||||||
|
return get_token_usage_stats(agent_id, days)
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/conversation/stats")
|
||||||
|
def conversation_stats(days: int = 7):
|
||||||
|
from .db import get_conversation_stats
|
||||||
|
return get_conversation_stats(days)
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/activity")
|
||||||
|
def activity_feed(limit: int = 50, offset: int = 0):
|
||||||
|
return get_activity_feed(limit, offset)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Realestate Agent Push Endpoint ---
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class RealestateNotifyBody(BaseModel):
|
||||||
|
matches: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/agent-office/realestate/notify")
|
||||||
|
async def realestate_notify(body: RealestateNotifyBody):
|
||||||
|
agent = get_agent("realestate")
|
||||||
|
if agent is None:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=503, detail="RealestateAgent not initialized")
|
||||||
|
return await agent.on_new_matches(body.matches)
|
||||||
|
|
||||||
|
|
||||||
|
# --- YouTube Research Agent Endpoints ---
|
||||||
|
|
||||||
|
class YouTubeResearchBody(BaseModel):
|
||||||
|
countries: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/agent-office/youtube/research")
|
||||||
|
async def trigger_youtube_research(body: Optional[YouTubeResearchBody] = None):
|
||||||
|
agent = get_agent("youtube")
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=503, detail="YouTubeResearchAgent 없음")
|
||||||
|
params = {}
|
||||||
|
if body and body.countries:
|
||||||
|
params["countries"] = body.countries
|
||||||
|
result = await agent.on_command("research", params)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/youtube/research/status")
|
||||||
|
def youtube_research_status():
|
||||||
|
job = get_latest_youtube_research_job()
|
||||||
|
if not job:
|
||||||
|
return {"status": "never_run"}
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
# --- Lotto Signal Endpoints ---
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/lotto/signals")
|
||||||
|
async def list_lotto_signals(days: int = 7):
|
||||||
|
"""시그널 이력 (모든 fire_level)."""
|
||||||
|
from .db import get_signals_history
|
||||||
|
return {"items": get_signals_history(days=days)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/lotto/baselines")
|
||||||
|
async def list_lotto_baselines():
|
||||||
|
"""현재 baseline μ/σ + window 상태."""
|
||||||
|
from .db import get_all_baselines
|
||||||
|
return {"items": get_all_baselines()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/agent-office/lotto/signal-check")
|
||||||
|
async def trigger_signal_check(source: str = "light"):
|
||||||
|
"""수동 트리거 (디버그·테스트용). source ∈ {light, sim, deep}."""
|
||||||
|
if source not in ("light", "sim", "deep"):
|
||||||
|
raise HTTPException(status_code=400, detail="source must be light/sim/deep")
|
||||||
|
agent = AGENT_REGISTRY.get("lotto")
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=503, detail="lotto agent not registered")
|
||||||
|
return await agent.run_signal_check(source=source)
|
||||||
78
agent-office/app/models.py
Normal file
78
agent-office/app/models.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List, Literal
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRequest(BaseModel):
|
||||||
|
agent: str
|
||||||
|
action: str
|
||||||
|
params: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRequest(BaseModel):
|
||||||
|
agent: str
|
||||||
|
task_id: str
|
||||||
|
approved: bool
|
||||||
|
feedback: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentConfigUpdate(BaseModel):
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
schedule_config: Optional[dict] = None
|
||||||
|
custom_config: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PriceAlertConfig(BaseModel):
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
target_price: float
|
||||||
|
direction: str # "above" or "below"
|
||||||
|
|
||||||
|
|
||||||
|
class ComposeCommand(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
style: Optional[str] = None
|
||||||
|
model: Optional[str] = "V4"
|
||||||
|
instrumental: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class TarotCardDraw(BaseModel):
|
||||||
|
position: str
|
||||||
|
card_id: str
|
||||||
|
reversed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TarotInterpretRequest(BaseModel):
|
||||||
|
spread_type: Literal["one_card", "three_card"]
|
||||||
|
category: Optional[str] = None
|
||||||
|
question: Optional[str] = None
|
||||||
|
cards: List[TarotCardDraw]
|
||||||
|
cards_reference: str = Field(..., min_length=1)
|
||||||
|
context_meta: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class TarotInterpretResponse(BaseModel):
|
||||||
|
interpretation_json: dict
|
||||||
|
model: str
|
||||||
|
tokens_in: int
|
||||||
|
tokens_out: int
|
||||||
|
cost_usd: float
|
||||||
|
latency_ms: int
|
||||||
|
reroll_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TarotSaveRequest(BaseModel):
|
||||||
|
spread_type: Literal["one_card", "three_card"]
|
||||||
|
category: Optional[str] = None
|
||||||
|
question: Optional[str] = None
|
||||||
|
cards: List[TarotCardDraw]
|
||||||
|
interpretation_json: dict
|
||||||
|
model: str
|
||||||
|
tokens_in: int
|
||||||
|
tokens_out: int
|
||||||
|
cost_usd: float
|
||||||
|
confidence: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TarotPatchRequest(BaseModel):
|
||||||
|
favorite: Optional[bool] = None
|
||||||
|
note: Optional[str] = None
|
||||||
0
agent-office/app/notifiers/__init__.py
Normal file
0
agent-office/app/notifiers/__init__.py
Normal file
227
agent-office/app/notifiers/telegram_lotto.py
Normal file
227
agent-office/app/notifiers/telegram_lotto.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
|
# 기존 에이전트들과 동일한 패턴: send_raw(text, reply_markup=None, chat_id=None)
|
||||||
|
# chat_id 생략 시 기본 TELEGRAM_CHAT_ID로 자동 발송.
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-office")
|
||||||
|
|
||||||
|
LOTTO_URL = "https://gahusb.synology.me/lotto"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_briefing(payload: Dict[str, Any]) -> str:
|
||||||
|
draw_no = payload["draw_no"]
|
||||||
|
nar = payload["narrative"]
|
||||||
|
conf = payload["confidence"]
|
||||||
|
|
||||||
|
# 분배 칩 — core 5세트의 risk_tag 빈도
|
||||||
|
core = payload["picks"]["core"]
|
||||||
|
role_count = {"안정": 0, "균형": 0, "공격": 0}
|
||||||
|
for p in core:
|
||||||
|
role_count[p["risk_tag"]] = role_count.get(p["risk_tag"], 0) + 1
|
||||||
|
chip = " · ".join(f"{k} {v}" for k, v in role_count.items() if v)
|
||||||
|
|
||||||
|
msg = [
|
||||||
|
f"🎟 {draw_no}회 · 큐레이션 떴음",
|
||||||
|
"",
|
||||||
|
f"\"{nar['headline']}\"",
|
||||||
|
f"신뢰도 {conf} · 분배 {chip}",
|
||||||
|
]
|
||||||
|
retro = nar.get("retrospective") or ""
|
||||||
|
if retro:
|
||||||
|
msg += ["", f"▸ 회고: {retro}"]
|
||||||
|
msg += ["", f"👉 결정 카드 보러가기 ({LOTTO_URL})"]
|
||||||
|
return "\n".join(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_prize_alert(event: Dict[str, Any]) -> str:
|
||||||
|
return (
|
||||||
|
"🚨 로또 당첨 가능성!\n"
|
||||||
|
f"{event['draw_no']}회 — {event['match_count']}개 일치\n"
|
||||||
|
f"번호: {', '.join(str(n) for n in event['numbers'])}\n"
|
||||||
|
"동행복권에서 즉시 확인하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_curator_briefing(payload: Dict[str, Any]) -> None:
|
||||||
|
text = _format_briefing(payload)
|
||||||
|
try:
|
||||||
|
await send_raw(text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[telegram_lotto] briefing send failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_prize_alert(event: Dict[str, Any]) -> None:
|
||||||
|
text = _format_prize_alert(event)
|
||||||
|
try:
|
||||||
|
await send_raw(text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[telegram_lotto] prize alert send failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 능동 시그널 알림 (urgent + digest) ----------
|
||||||
|
|
||||||
|
_METRIC_LABEL = {
|
||||||
|
"sim_signal": "Sim Consensus",
|
||||||
|
"drift": "Strategy Drift",
|
||||||
|
"confidence": "Confidence",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_urgent_signal(event: Dict[str, Any]) -> str:
|
||||||
|
"""긴급 시그널 텔레그램 메시지 포맷."""
|
||||||
|
triggered = event.get("triggered_at", "")[:19].replace("T", " ")
|
||||||
|
results = event.get("results", [])
|
||||||
|
fired = [r for r in results if r.get("fire_level") in ("normal", "urgent")]
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"🚨 로또 능동 신호",
|
||||||
|
"",
|
||||||
|
f"[{triggered}]",
|
||||||
|
f"강한 시그널 {len(fired)}종 발화:",
|
||||||
|
]
|
||||||
|
for r in fired:
|
||||||
|
label = _METRIC_LABEL.get(r["metric"], r["metric"])
|
||||||
|
v = r.get("value")
|
||||||
|
mu = r.get("baseline_mu")
|
||||||
|
sigma = r.get("baseline_sigma")
|
||||||
|
z = r.get("z_score")
|
||||||
|
v_text = f"{v:.2f}" if v is not None else "N/A"
|
||||||
|
if mu is not None and sigma is not None and z is not None:
|
||||||
|
lines.append(f"• {label} {v_text} (μ={mu:.2f}, σ={sigma:.2f}) z={z:.1f}")
|
||||||
|
else:
|
||||||
|
lines.append(f"• {label} {v_text}")
|
||||||
|
|
||||||
|
# drift 페이로드 — 어떤 전략이 변동했는지 한 줄
|
||||||
|
for r in fired:
|
||||||
|
if r["metric"] == "drift":
|
||||||
|
wn = (r.get("payload") or {}).get("weights_now") or {}
|
||||||
|
wp = (r.get("payload") or {}).get("weights_prev") or {}
|
||||||
|
if wn and wp:
|
||||||
|
diffs = {k: wn.get(k, 0) - wp.get(k, 0) for k in (set(wn) | set(wp))}
|
||||||
|
top = sorted(diffs.items(), key=lambda kv: abs(kv[1]), reverse=True)[:2]
|
||||||
|
detail = ", ".join(f"{k} {'+' if d>=0 else ''}{d*100:.0f}%p" for k, d in top)
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"요인: {detail}")
|
||||||
|
break
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"[자세히 보기] ({LOTTO_URL}/agent)")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_signal_digest(digest: Dict[str, Any]) -> str:
|
||||||
|
"""일일 요약 메시지. 발화 0건이면 빈 문자열 (발송 skip 신호)."""
|
||||||
|
fired = int(digest.get("fired", 0))
|
||||||
|
if fired == 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
signals_list = digest.get("signals", [])
|
||||||
|
evaluated = digest.get("evaluated", 0)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"📊 로또 일일 요약 (지난 24h)",
|
||||||
|
"",
|
||||||
|
f"평가 {evaluated}회 / 발화 {fired}회",
|
||||||
|
]
|
||||||
|
for s in signals_list:
|
||||||
|
label = _METRIC_LABEL.get(s["metric"], s["metric"])
|
||||||
|
z = s.get("z_score")
|
||||||
|
when = (s.get("triggered_at") or "")[11:16] # HH:MM
|
||||||
|
z_text = f"z={z:.1f}" if z is not None else "z=-"
|
||||||
|
lines.append(f"• {label:14s} {s['fire_level']:6s} {z_text} ({when})")
|
||||||
|
|
||||||
|
weights_trend = digest.get("weights_trend") or {}
|
||||||
|
if weights_trend:
|
||||||
|
lines += ["", "전략 가중치 추세 (최근 8회 baseline):"]
|
||||||
|
for strategy, delta in sorted(weights_trend.items(), key=lambda kv: -abs(kv[1])):
|
||||||
|
arrow = "↑" if delta > 0.01 else ("↓" if delta < -0.01 else "→")
|
||||||
|
lines.append(f" {strategy:12s} {arrow} {delta*100:+.0f}%")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_urgent_signal(event: Dict[str, Any]) -> None:
|
||||||
|
text = _format_urgent_signal(event)
|
||||||
|
try:
|
||||||
|
await send_raw(text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[telegram_lotto] urgent signal send failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_signal_summary(digest: Dict[str, Any]) -> None:
|
||||||
|
text = _format_signal_digest(digest)
|
||||||
|
if not text:
|
||||||
|
return # 발화 0건이면 발송 skip
|
||||||
|
try:
|
||||||
|
await send_raw(text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[telegram_lotto] digest send failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Weight Evolver 주간 리포트 ----------
|
||||||
|
|
||||||
|
_DAY_NAMES = ["월", "화", "수", "목", "금", "토"]
|
||||||
|
_METRIC_NAMES = ["freq", "finger", "gap", "cooccur", "divers"]
|
||||||
|
_REASON_LABEL = {
|
||||||
|
"winner_4plus": "4개 이상 일치 → base 교체",
|
||||||
|
"ema_blend": "3개 일치 → EMA blend (0.3)",
|
||||||
|
"unchanged": "유효 성과 없음 → base 유지",
|
||||||
|
"cold_start": "초기 균등 적용",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> str:
|
||||||
|
"""주간 weight evolution 텔레그램 메시지. ok=False 또는 winner 없으면 빈 문자열."""
|
||||||
|
if not eval_result or "winner" not in eval_result:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
draw_no = eval_result.get("draw_no", "?")
|
||||||
|
winner = eval_result["winner"]
|
||||||
|
new_base = eval_result.get("new_base") or [0.0] * 5
|
||||||
|
reason = eval_result.get("update_reason", "")
|
||||||
|
dow = winner.get("day_of_week", 0)
|
||||||
|
day_name = _DAY_NAMES[dow] if 0 <= dow < len(_DAY_NAMES) else "?"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"🧬 로또 학습 주간 리포트 ({draw_no}회차)",
|
||||||
|
"",
|
||||||
|
f"이번주 시도: 6일 × {winner.get('n_picks', 5)}세트",
|
||||||
|
"",
|
||||||
|
f"🏆 Winner: {day_name}요일",
|
||||||
|
f" W = [" + ", ".join(
|
||||||
|
f"{name} {w:.2f}" for name, w in zip(_METRIC_NAMES, winner["weight"])
|
||||||
|
) + "]",
|
||||||
|
f" 최고 적중: {winner.get('max_correct', 0)}개 일치 (max={winner.get('max_correct', 0)})",
|
||||||
|
f" 평균 점수: {winner.get('avg_score', 0):.2f}",
|
||||||
|
"",
|
||||||
|
f"📊 다음주 base 변경 ({reason}):",
|
||||||
|
]
|
||||||
|
# 우선순위: eval_result.previous_base > current_base (eval 직후 stale) > 균등 fallback
|
||||||
|
base_now = eval_result.get("previous_base") or current_base or [0.2] * 5
|
||||||
|
for i, (cur, new) in enumerate(zip(base_now, new_base)):
|
||||||
|
diff = new - cur
|
||||||
|
if abs(diff) < 0.005:
|
||||||
|
marker = "="
|
||||||
|
elif diff > 0:
|
||||||
|
marker = "+" if diff < 0.05 else "++"
|
||||||
|
else:
|
||||||
|
marker = "-" if diff > -0.05 else "--"
|
||||||
|
lines.append(f" {_METRIC_NAMES[i]:8s} {cur:.2f} → {new:.2f} ({marker})")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f" → {_REASON_LABEL.get(reason, reason)}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"[웹에서 차트 보기] ({LOTTO_URL}/evolver)")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> None:
|
||||||
|
text = _format_evolution_report(eval_result, current_base)
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await send_raw(text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[telegram_lotto] evolution report send failed: {e}")
|
||||||
0
agent-office/app/routers/__init__.py
Normal file
0
agent-office/app/routers/__init__.py
Normal file
20
agent-office/app/routers/notify.py
Normal file
20
agent-office/app/routers/notify.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""다른 서비스가 트리거하는 웹훅 — 현재 lotto-backend → 텔레그램 푸시."""
|
||||||
|
from typing import List
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from ..notifiers.telegram_lotto import send_prize_alert
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/agent-office/notify")
|
||||||
|
|
||||||
|
|
||||||
|
class LottoPrizeEvent(BaseModel):
|
||||||
|
draw_no: int
|
||||||
|
match_count: int
|
||||||
|
numbers: List[int]
|
||||||
|
purchase_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lotto-prize")
|
||||||
|
async def lotto_prize(body: LottoPrizeEvent):
|
||||||
|
await send_prize_alert(body.model_dump())
|
||||||
|
return {"ok": True}
|
||||||
70
agent-office/app/routers/tarot.py
Normal file
70
agent-office/app/routers/tarot.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Tarot Lab 엔드포인트 — interpret + readings CRUD."""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
TarotInterpretRequest,
|
||||||
|
TarotInterpretResponse,
|
||||||
|
TarotSaveRequest,
|
||||||
|
TarotPatchRequest,
|
||||||
|
)
|
||||||
|
from ..tarot import pipeline
|
||||||
|
from .. import db as db_module
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/agent-office/tarot")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/interpret", response_model=TarotInterpretResponse)
|
||||||
|
async def interpret_endpoint(req: TarotInterpretRequest):
|
||||||
|
try:
|
||||||
|
result = await pipeline.interpret(req)
|
||||||
|
except pipeline.TarotError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/readings")
|
||||||
|
async def save_reading(req: TarotSaveRequest):
|
||||||
|
rid = db_module.save_tarot_reading(req.model_dump())
|
||||||
|
row = db_module.get_tarot_reading(rid)
|
||||||
|
return {"id": rid, "created_at": row["created_at"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/readings")
|
||||||
|
async def list_readings(
|
||||||
|
page: int = 1,
|
||||||
|
size: int = 20,
|
||||||
|
favorite: bool | None = None,
|
||||||
|
spread_type: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
):
|
||||||
|
return db_module.list_tarot_readings(
|
||||||
|
page=page, size=size,
|
||||||
|
favorite=favorite, spread_type=spread_type, category=category,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/readings/{reading_id}")
|
||||||
|
async def get_reading(reading_id: int):
|
||||||
|
row = db_module.get_tarot_reading(reading_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="reading not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/readings/{reading_id}")
|
||||||
|
async def patch_reading(reading_id: int, req: TarotPatchRequest):
|
||||||
|
row = db_module.get_tarot_reading(reading_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="reading not found")
|
||||||
|
db_module.update_tarot_reading(reading_id, **req.model_dump(exclude_none=True))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/readings/{reading_id}")
|
||||||
|
async def delete_reading(reading_id: int):
|
||||||
|
row = db_module.get_tarot_reading(reading_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="reading not found")
|
||||||
|
db_module.delete_tarot_reading(reading_id)
|
||||||
|
return {"ok": True}
|
||||||
120
agent-office/app/scheduler.py
Normal file
120
agent-office/app/scheduler.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import asyncio
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
|
from .agents import AGENT_REGISTRY
|
||||||
|
|
||||||
|
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||||
|
|
||||||
|
async def _run_stock_schedule():
|
||||||
|
agent = AGENT_REGISTRY.get("stock")
|
||||||
|
if agent:
|
||||||
|
await agent.on_schedule()
|
||||||
|
|
||||||
|
async def _run_stock_screener():
|
||||||
|
agent = AGENT_REGISTRY.get("stock")
|
||||||
|
if agent:
|
||||||
|
await agent.on_screener_schedule()
|
||||||
|
|
||||||
|
async def _run_stock_ai_news():
|
||||||
|
agent = AGENT_REGISTRY.get("stock")
|
||||||
|
if agent:
|
||||||
|
await agent.on_ai_news_schedule()
|
||||||
|
|
||||||
|
async def _run_insta_schedule():
|
||||||
|
agent = AGENT_REGISTRY.get("insta")
|
||||||
|
if agent:
|
||||||
|
await agent.on_schedule()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_insta_trends_collect():
|
||||||
|
agent = AGENT_REGISTRY.get("insta")
|
||||||
|
if agent:
|
||||||
|
await agent.on_command("collect_trends", {})
|
||||||
|
|
||||||
|
async def _run_lotto_schedule():
|
||||||
|
agent = AGENT_REGISTRY.get("lotto")
|
||||||
|
if agent:
|
||||||
|
await agent.on_schedule()
|
||||||
|
|
||||||
|
async def _run_lotto_light_check():
|
||||||
|
agent = AGENT_REGISTRY.get("lotto")
|
||||||
|
if agent:
|
||||||
|
await agent.run_signal_check(source="light")
|
||||||
|
|
||||||
|
async def _run_lotto_sim_check():
|
||||||
|
agent = AGENT_REGISTRY.get("lotto")
|
||||||
|
if agent:
|
||||||
|
await agent.run_signal_check(source="sim")
|
||||||
|
|
||||||
|
async def _run_lotto_deep_check():
|
||||||
|
agent = AGENT_REGISTRY.get("lotto")
|
||||||
|
if agent:
|
||||||
|
await agent.run_signal_check(source="deep")
|
||||||
|
|
||||||
|
async def _run_lotto_daily_digest():
|
||||||
|
agent = AGENT_REGISTRY.get("lotto")
|
||||||
|
if agent:
|
||||||
|
await agent.run_daily_digest()
|
||||||
|
|
||||||
|
async def _run_lotto_weekly_evolution_report():
|
||||||
|
agent = AGENT_REGISTRY.get("lotto")
|
||||||
|
if agent:
|
||||||
|
await agent.run_weekly_evolution_report()
|
||||||
|
|
||||||
|
async def _run_lotto_sync_evolver_activity():
|
||||||
|
agent = AGENT_REGISTRY.get("lotto")
|
||||||
|
if agent:
|
||||||
|
await agent.sync_evolver_activity()
|
||||||
|
|
||||||
|
async def _run_youtube_research():
|
||||||
|
agent = AGENT_REGISTRY.get("youtube")
|
||||||
|
if agent:
|
||||||
|
await agent.on_schedule()
|
||||||
|
|
||||||
|
async def _send_youtube_weekly_report():
|
||||||
|
agent = AGENT_REGISTRY.get("youtube")
|
||||||
|
if agent:
|
||||||
|
await agent.send_weekly_report()
|
||||||
|
|
||||||
|
async def _poll_pipelines():
|
||||||
|
agent = AGENT_REGISTRY.get("youtube_publisher")
|
||||||
|
if agent:
|
||||||
|
await agent.poll_state_changes()
|
||||||
|
|
||||||
|
def init_scheduler():
|
||||||
|
scheduler.add_job(_run_stock_schedule, "cron", hour=7, minute=30, id="stock_news")
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_stock_screener,
|
||||||
|
"cron",
|
||||||
|
day_of_week="mon-fri",
|
||||||
|
hour=16,
|
||||||
|
minute=30,
|
||||||
|
id="stock_screener",
|
||||||
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_stock_ai_news,
|
||||||
|
"cron",
|
||||||
|
day_of_week="mon-fri",
|
||||||
|
hour=8,
|
||||||
|
minute=0,
|
||||||
|
id="stock_ai_news_sentiment",
|
||||||
|
)
|
||||||
|
scheduler.add_job(_run_insta_schedule, "cron", hour=9, minute=30, id="insta_pipeline")
|
||||||
|
# 외부 트렌드 수집은 장 마감 후 16:40 — 9시 주식 활발 시간대 NAS 자원 회피.
|
||||||
|
# screener(16:30)와 10분 스태거: Celeron 2C/2.0GHz 동시 실행 시 CPU 폭주 방지 (CHECK_POINT FU-A)
|
||||||
|
scheduler.add_job(_run_insta_trends_collect, "cron", hour=16, minute=40, id="insta_trends_collect")
|
||||||
|
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=5, id="lotto_curate")
|
||||||
|
scheduler.add_job(_run_lotto_light_check, "cron", hour=9, minute=15, id="lotto_light_check")
|
||||||
|
scheduler.add_job(_run_lotto_sim_check, "cron", minute=15, hour="0,4,8,12,16,20", id="lotto_sim_check")
|
||||||
|
scheduler.add_job(_run_lotto_deep_check, "cron", day_of_week="sun,wed", hour=21, minute=15, id="lotto_deep_check")
|
||||||
|
scheduler.add_job(_run_lotto_daily_digest, "cron", hour=9, minute=25, id="lotto_digest")
|
||||||
|
scheduler.add_job(_run_lotto_weekly_evolution_report, "cron", day_of_week="sat", hour=22, minute=15, id="lotto_evolution_weekly")
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_lotto_sync_evolver_activity,
|
||||||
|
"cron", hour=9, minute=30,
|
||||||
|
id="lotto_evolver_activity_sync",
|
||||||
|
)
|
||||||
|
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, id="youtube_research")
|
||||||
|
scheduler.add_job(_send_youtube_weekly_report, "cron", day_of_week="mon", hour=8, minute=0, id="youtube_weekly_report")
|
||||||
|
scheduler.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
|
||||||
|
scheduler.start()
|
||||||
396
agent-office/app/service_proxy.py
Normal file
396
agent-office/app/service_proxy.py
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import httpx
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from .config import STOCK_URL, MUSIC_LAB_URL, INSTA_LAB_URL, REALESTATE_LAB_URL
|
||||||
|
|
||||||
|
_client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
|
async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[str, Any]]:
|
||||||
|
params = {"limit": limit}
|
||||||
|
if category:
|
||||||
|
params["category"] = category
|
||||||
|
resp = await _client.get(f"{STOCK_URL}/api/stock/news", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def fetch_stock_indices() -> Dict[str, Any]:
|
||||||
|
resp = await _client.get(f"{STOCK_URL}/api/stock/indices")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def summarize_stock_news(limit: int = 15) -> Dict[str, Any]:
|
||||||
|
"""stock의 AI 요약 엔드포인트 호출.
|
||||||
|
반환: {"summary": str, "tokens": {...}, "model": str, "duration_ms": int, "article_count": int}
|
||||||
|
"""
|
||||||
|
# stock 내부 Ollama 호출이 180s까지 가능하므로 여유있게 200s
|
||||||
|
async with httpx.AsyncClient(timeout=200.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{STOCK_URL}/api/stock/news/summarize",
|
||||||
|
json={"limit": limit},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_screener_snapshot() -> Dict[str, Any]:
|
||||||
|
"""stock의 KRX 일봉 스냅샷 갱신 (스크리너 실행 전 호출).
|
||||||
|
|
||||||
|
네이버 금융 일괄 다운로드라 보통 30~120s, 여유있게 180s.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||||
|
resp = await client.post(f"{STOCK_URL}/api/stock/screener/snapshot/refresh")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_ai_news_sentiment() -> Dict[str, Any]:
|
||||||
|
"""stock의 AI 뉴스 sentiment 분석 트리거 (08:00 cron).
|
||||||
|
|
||||||
|
네이버 100종목 스크래핑 + Claude Haiku 100콜 병렬 = 약 30-60초.
|
||||||
|
여유있게 240s timeout.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=240.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{STOCK_URL}/api/stock/screener/snapshot/refresh-news-sentiment"
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_stock_screener(mode: str = "auto") -> Dict[str, Any]:
|
||||||
|
"""stock의 스크리너 실행.
|
||||||
|
|
||||||
|
반환 status:
|
||||||
|
- 'skipped_holiday': 공휴일/주말 — telegram_payload 없음
|
||||||
|
- 'success': telegram_payload 동봉
|
||||||
|
엔진 자체는 수 초 내 끝나지만, 컨텍스트 로드+200종목 처리 여유 180s.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{STOCK_URL}/api/stock/screener/run",
|
||||||
|
json={"mode": mode},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_stock_news() -> Dict[str, Any]:
|
||||||
|
"""stock의 수동 뉴스 스크랩 트리거 — DB에 최신 뉴스 저장.
|
||||||
|
|
||||||
|
아침 브리핑 직전 호출하여 어제 데이터가 아닌 오늘 새벽 뉴스를 보장한다.
|
||||||
|
네이버 금융 단일 요청이라 보통 수 초 내 완료, 여유있게 60s.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
resp = await client.post(f"{STOCK_URL}/api/stock/scrap")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def generate_music(payload: dict) -> Dict[str, Any]:
|
||||||
|
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def get_music_status(task_id: str) -> Dict[str, Any]:
|
||||||
|
resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/status/{task_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def get_music_credits() -> Dict[str, Any]:
|
||||||
|
resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/credits")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- insta-lab ---
|
||||||
|
|
||||||
|
async def insta_collect(categories: Optional[list] = None) -> Dict[str, Any]:
|
||||||
|
"""뉴스 수집 트리거 → task_id 반환."""
|
||||||
|
payload = {"categories": categories} if categories else {}
|
||||||
|
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/news/collect", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_extract(categories: Optional[list] = None) -> Dict[str, Any]:
|
||||||
|
payload = {"categories": categories} if categories else {}
|
||||||
|
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/keywords/extract", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_list_keywords(category: Optional[str] = None,
|
||||||
|
used: Optional[bool] = None) -> List[Dict[str, Any]]:
|
||||||
|
params: Dict[str, Any] = {}
|
||||||
|
if category:
|
||||||
|
params["category"] = category
|
||||||
|
if used is not None:
|
||||||
|
params["used"] = "true" if used else "false"
|
||||||
|
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/keywords", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_get_keyword(keyword_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
items = await insta_list_keywords()
|
||||||
|
for it in items:
|
||||||
|
if it["id"] == keyword_id:
|
||||||
|
return it
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_create_slate(keyword: str, category: str, keyword_id: Optional[int] = None) -> Dict[str, Any]:
|
||||||
|
resp = await _client.post(
|
||||||
|
f"{INSTA_LAB_URL}/api/insta/slates",
|
||||||
|
json={"keyword": keyword, "category": category, "keyword_id": keyword_id},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_task_status(task_id: str) -> Dict[str, Any]:
|
||||||
|
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/tasks/{task_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_get_slate(slate_id: int) -> Dict[str, Any]:
|
||||||
|
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_get_asset_bytes(slate_id: int, page: int) -> bytes:
|
||||||
|
"""카드 PNG 바이트를 가져와 텔레그램 미디어 그룹에 첨부."""
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.get(f"{INSTA_LAB_URL}/api/insta/slates/{slate_id}/assets/{page}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_collect_trends(categories: Optional[list] = None) -> Dict[str, Any]:
|
||||||
|
payload = {"categories": categories} if categories else {}
|
||||||
|
resp = await _client.post(f"{INSTA_LAB_URL}/api/insta/trends/collect", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_list_trends(source: Optional[str] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
days: int = 1) -> List[Dict[str, Any]]:
|
||||||
|
params: Dict[str, Any] = {"days": days}
|
||||||
|
if source:
|
||||||
|
params["source"] = source
|
||||||
|
if category:
|
||||||
|
params["category"] = category
|
||||||
|
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/trends", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_get_preferences() -> Dict[str, float]:
|
||||||
|
resp = await _client.get(f"{INSTA_LAB_URL}/api/insta/preferences")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return {p["category"]: p["weight"] for p in resp.json().get("categories", [])}
|
||||||
|
|
||||||
|
|
||||||
|
async def insta_put_preferences(weights: Dict[str, float]) -> Dict[str, Any]:
|
||||||
|
resp = await _client.put(
|
||||||
|
f"{INSTA_LAB_URL}/api/insta/preferences",
|
||||||
|
json={"categories": weights},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- realestate-lab ---
|
||||||
|
|
||||||
|
async def realestate_collect() -> Dict[str, Any]:
|
||||||
|
"""청약 공고 수동 수집 트리거"""
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
resp = await client.post(f"{REALESTATE_LAB_URL}/api/realestate/collect")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def realestate_matches(limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""realestate-lab의 GET /api/realestate/matches 호출."""
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{REALESTATE_LAB_URL}/api/realestate/matches",
|
||||||
|
params={"size": limit},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("items", [])
|
||||||
|
|
||||||
|
|
||||||
|
async def realestate_dashboard() -> Dict[str, Any]:
|
||||||
|
resp = await _client.get(f"{REALESTATE_LAB_URL}/api/realestate/dashboard")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def realestate_mark_read(match_id: int) -> Dict[str, Any]:
|
||||||
|
resp = await _client.patch(f"{REALESTATE_LAB_URL}/api/realestate/matches/{match_id}/read")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def realestate_bookmark_toggle(announcement_id: int) -> Dict[str, Any]:
|
||||||
|
"""realestate-lab의 PATCH /api/realestate/announcements/{id}/bookmark 호출."""
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
f"{REALESTATE_LAB_URL}/api/realestate/announcements/{announcement_id}/bookmark"
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- lotto-backend ---
|
||||||
|
|
||||||
|
async def lotto_candidates(n: int = 20) -> Dict[str, Any]:
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/curator/candidates", params={"n": n})
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_context() -> Dict[str, Any]:
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/curator/context")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_save_briefing(payload: dict) -> Dict[str, Any]:
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.post(f"{LOTTO_BACKEND_URL}/api/lotto/briefing", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_review_latest() -> Optional[Dict[str, Any]]:
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/latest")
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_review_by_draw(draw_no: int) -> Optional[Dict[str, Any]]:
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/review/{draw_no}")
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_reviews_history(limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.get(
|
||||||
|
f"{LOTTO_BACKEND_URL}/api/lotto/review/history",
|
||||||
|
params={"limit": limit},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("reviews", [])
|
||||||
|
|
||||||
|
|
||||||
|
# --- music-lab pipeline (YouTube publisher orchestration) ---
|
||||||
|
|
||||||
|
async def list_active_pipelines() -> list[dict]:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline?status=active")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("pipelines", [])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pipeline(pid: int) -> dict:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def post_pipeline_feedback(pid: int, step: str, intent: str,
|
||||||
|
feedback_text: Optional[str] = None) -> dict:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}/feedback",
|
||||||
|
json={"step": step, "intent": intent, "feedback_text": feedback_text},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def save_pipeline_telegram_msg(pid: int, step: str, msg_id: int) -> None:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
await client.patch(
|
||||||
|
f"{MUSIC_LAB_URL}/api/music/pipeline/{pid}/telegram-msg",
|
||||||
|
json={"step": step, "message_id": msg_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def lookup_pipeline_by_msg(msg_id: int) -> Optional[dict]:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(f"{MUSIC_LAB_URL}/api/music/pipeline/lookup-by-msg/{msg_id}")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_best() -> List[Dict[str, Any]]:
|
||||||
|
"""GET /api/lotto/best — best_picks 20개 (numbers + scores 5종)."""
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/best")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
items = data.get("items") if isinstance(data, dict) else data
|
||||||
|
return items or []
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_strategy_weights() -> Dict[str, float]:
|
||||||
|
"""GET /api/lotto/strategy/weights — 전략별 가중치 dict."""
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/strategy/weights")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
weights = data.get("weights") if isinstance(data, dict) else data
|
||||||
|
if isinstance(weights, list):
|
||||||
|
return {item["strategy"]: float(item["weight"]) for item in weights}
|
||||||
|
return {k: float(v) for k, v in (weights or {}).items()}
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_latest_draw() -> Optional[int]:
|
||||||
|
"""GET /api/lotto/latest — 최신 회차 번호만 반환."""
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
try:
|
||||||
|
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/latest")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
# /api/lotto/latest 응답 키: {"drawNo": N, ...}
|
||||||
|
# 하위 호환을 위해 drawNo, draw_no, drwNo, draw 순서로 시도
|
||||||
|
for key in ("drawNo", "draw_no", "drwNo", "draw"):
|
||||||
|
if isinstance(data, dict) and data.get(key):
|
||||||
|
return int(data[key])
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_evolver_status() -> Dict[str, Any]:
|
||||||
|
"""GET /api/lotto/evolver/status — 이번주 trials + 다음주 base 정보."""
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/status")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def lotto_evolver_evaluate() -> Dict[str, Any]:
|
||||||
|
"""POST /api/lotto/evolver/evaluate-now — 회고 트리거 (텔레그램 리포트용)."""
|
||||||
|
from .config import LOTTO_BACKEND_URL
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
resp = await client.post(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/evaluate-now")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
1
agent-office/app/tarot/__init__.py
Normal file
1
agent-office/app/tarot/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tarot Lab — Claude Sonnet 기반 evidence·interactions 해석 파이프라인."""
|
||||||
139
agent-office/app/tarot/pipeline.py
Normal file
139
agent-office/app/tarot/pipeline.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Tarot 파이프라인 — Claude Sonnet 호출 + 파싱 폴백 + reroll 1회."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import (
|
||||||
|
ANTHROPIC_API_KEY,
|
||||||
|
TAROT_MODEL,
|
||||||
|
TAROT_COST_INPUT_PER_M,
|
||||||
|
TAROT_COST_OUTPUT_PER_M,
|
||||||
|
TAROT_TIMEOUT_SEC,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-office.tarot")
|
||||||
|
from ..models import TarotInterpretRequest
|
||||||
|
from .prompt import SYSTEM_PROMPT, build_user_message
|
||||||
|
from .schema import validate_interpretation
|
||||||
|
|
||||||
|
|
||||||
|
API_URL = "https://api.anthropic.com/v1/messages"
|
||||||
|
|
||||||
|
|
||||||
|
class TarotError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def calc_cost(tokens_in: int, tokens_out: int) -> float:
|
||||||
|
return (
|
||||||
|
tokens_in / 1_000_000 * TAROT_COST_INPUT_PER_M
|
||||||
|
+ tokens_out / 1_000_000 * TAROT_COST_OUTPUT_PER_M
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_codeblock(text: str) -> str:
|
||||||
|
t = text.strip()
|
||||||
|
if t.startswith("```"):
|
||||||
|
t = t.strip("`")
|
||||||
|
if t.startswith("json"):
|
||||||
|
t = t[4:]
|
||||||
|
t = t.strip()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json(raw: str) -> dict:
|
||||||
|
cleaned = _strip_codeblock(raw)
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
start, end = cleaned.find("{"), cleaned.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned[start : end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict, str]:
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise TarotError("ANTHROPIC_API_KEY missing")
|
||||||
|
if feedback:
|
||||||
|
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마(시스템 지침)로 다시 응답.\n\n{user_text}"
|
||||||
|
payload = {
|
||||||
|
"model": TAROT_MODEL,
|
||||||
|
"max_tokens": 1400, # 응답 시간 단축 — 3-card spread evidence·interactions 포함 충분
|
||||||
|
"system": [{"type": "text", "text": SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"}}],
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"anthropic-beta": "prompt-caching-2024-07-31",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
started = time.monotonic()
|
||||||
|
async with httpx.AsyncClient(timeout=TAROT_TIMEOUT_SEC) as client:
|
||||||
|
r = await client.post(API_URL, headers=headers, json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
resp = r.json()
|
||||||
|
latency_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
raw_text = "".join(
|
||||||
|
b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"
|
||||||
|
)
|
||||||
|
usage = resp.get("usage", {}) or {}
|
||||||
|
tokens_in = int(usage.get("input_tokens", 0) or 0)
|
||||||
|
tokens_out = int(usage.get("output_tokens", 0) or 0)
|
||||||
|
logger.info("tarot claude call: latency=%dms, in=%d, out=%d", latency_ms, tokens_in, tokens_out)
|
||||||
|
parsed = _extract_json(raw_text)
|
||||||
|
meta = {
|
||||||
|
"tokens_in": tokens_in,
|
||||||
|
"tokens_out": tokens_out,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
}
|
||||||
|
return parsed, meta, raw_text
|
||||||
|
|
||||||
|
|
||||||
|
async def interpret(req: TarotInterpretRequest) -> Dict[str, Any]:
|
||||||
|
user_text = build_user_message(
|
||||||
|
question=req.question or "",
|
||||||
|
category=req.category or "",
|
||||||
|
spread_type=req.spread_type,
|
||||||
|
cards_reference=req.cards_reference,
|
||||||
|
context_meta=req.context_meta or {},
|
||||||
|
spread_count=len(req.cards),
|
||||||
|
)
|
||||||
|
|
||||||
|
total_in, total_out, total_latency = 0, 0, 0
|
||||||
|
last_error = ""
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
parsed, meta, _raw = await _call_claude(user_text, feedback=last_error)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise TarotError(f"Claude HTTP error: {e}") from e
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
last_error = f"JSON 파싱 실패: {e}"
|
||||||
|
continue
|
||||||
|
total_in += meta["tokens_in"]
|
||||||
|
total_out += meta["tokens_out"]
|
||||||
|
total_latency += meta["latency_ms"]
|
||||||
|
|
||||||
|
ok, err = validate_interpretation(parsed, req.spread_type)
|
||||||
|
if ok:
|
||||||
|
return {
|
||||||
|
"interpretation_json": parsed,
|
||||||
|
"model": TAROT_MODEL,
|
||||||
|
"tokens_in": total_in,
|
||||||
|
"tokens_out": total_out,
|
||||||
|
"cost_usd": calc_cost(total_in, total_out),
|
||||||
|
"latency_ms": total_latency,
|
||||||
|
"reroll_count": attempt,
|
||||||
|
}
|
||||||
|
last_error = err
|
||||||
|
|
||||||
|
raise TarotError(f"검증 실패 (reroll 2회): {last_error}")
|
||||||
108
agent-office/app/tarot/prompt.py
Normal file
108
agent-office/app/tarot/prompt.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tarot 프롬프트 — SYSTEM + build_user_message."""
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """당신은 라이더-웨이트(RWS) 타로 덱의 전통 상징체계에 정통한 타로 리더입니다.
|
||||||
|
사용자의 질문, 카테고리, 뽑힌 카드 각각의 정·역방향과 위치를 받아 근거 기반으로 해석합니다.
|
||||||
|
|
||||||
|
# 해석 원칙
|
||||||
|
1. 데이터 우선: "참고 카드 정보" 블록의 키워드·기본의미·상징만을 1차 근거로 사용.
|
||||||
|
외부 변형 의미·다른 덱 해석은 사용하지 않음.
|
||||||
|
2. 위치 의미 결합: 카드의 의미와 위치(과거/현재/미래 또는 오늘)를 명시적으로 결합해서 해석. evidence에 근거 기록.
|
||||||
|
3. 카드 간 상호작용 분석 (3장 스프레드):
|
||||||
|
- 시너지: 같은 슈트, 같은 원소, 메이저 비율, 정·역 흐름
|
||||||
|
- 충돌·전환: 슈트 충돌(컵-소드, 완드-펜타클), 정→역 전환, 메이저↔마이너 전환
|
||||||
|
4. 자기 성찰 톤: 운명론 단정 금지. "…할 가능성이 있어 보입니다" 같은 표현.
|
||||||
|
5. 카테고리 컨텍스트: 동일 카드라도 카테고리에 따라 강조점이 달라야 함.
|
||||||
|
6. 질문 직접 응답: 사용자 질문을 evidence·advice에서 인용·반영.
|
||||||
|
|
||||||
|
# 응답 형식 (strict JSON only — 코드블록 없이 raw JSON)
|
||||||
|
{
|
||||||
|
"summary": "전체 흐름 한 단락 (3~4문장)",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"position": "<위치 라벨>",
|
||||||
|
"card": "<card_id>",
|
||||||
|
"reversed": <bool>,
|
||||||
|
"interpretation": "3~4문장",
|
||||||
|
"evidence": {
|
||||||
|
"card_meaning_used": "참고 카드 정보에서 인용한 키워드·상징",
|
||||||
|
"position_logic": "왜 이 위치에 이렇게 적용되는지 (1~2문장)",
|
||||||
|
"category_lens": "카테고리 관점에서 부각되는 면 (1문장)"
|
||||||
|
},
|
||||||
|
"advice": "1문장"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interactions": [
|
||||||
|
{ "type": "synergy"|"conflict"|"transition",
|
||||||
|
"between": ["<card_id>", "<card_id>"],
|
||||||
|
"explanation": "1~2문장" }
|
||||||
|
],
|
||||||
|
"advice": "2문장. interactions를 1개 이상 참조할 것.",
|
||||||
|
"warning": "역방향·충돌 경계 (없으면 null)",
|
||||||
|
"confidence": "high"|"medium"|"low"
|
||||||
|
}
|
||||||
|
|
||||||
|
# confidence 판정 기준
|
||||||
|
- high: 3장 모두 한 방향 서사 또는 명확한 전환
|
||||||
|
- medium: 2장 일관, 1장 별도 신호
|
||||||
|
- low: 카드 간 의미 충돌이 커서 명확한 흐름 잡기 어려움
|
||||||
|
|
||||||
|
# 금지사항
|
||||||
|
- 참고 카드 정보에 없는 상징 도입 금지
|
||||||
|
- 역방향 카드를 정방향처럼 다루지 말 것
|
||||||
|
- "신비롭게 들리는" 문구로 채우지 말 것 — evidence에 인용·근거 명시
|
||||||
|
- JSON 외 텍스트 금지
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SPREAD_NAMES = {
|
||||||
|
"one_card": "오늘의 카드",
|
||||||
|
"three_card": "3장 스프레드 (과거·현재·미래)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_message(
|
||||||
|
question: str,
|
||||||
|
category: str,
|
||||||
|
spread_type: str,
|
||||||
|
cards_reference: str,
|
||||||
|
context_meta: dict,
|
||||||
|
spread_count: int,
|
||||||
|
) -> str:
|
||||||
|
q = question or "(질문 없음)"
|
||||||
|
cat = category or "일반"
|
||||||
|
spread_name = SPREAD_NAMES.get(spread_type, spread_type)
|
||||||
|
|
||||||
|
meta_lines = []
|
||||||
|
if context_meta:
|
||||||
|
if "major_minor_ratio" in context_meta:
|
||||||
|
meta_lines.append(f"- 메이저:마이너 비율: {context_meta['major_minor_ratio']}")
|
||||||
|
if "element_distribution" in context_meta:
|
||||||
|
ed = context_meta["element_distribution"]
|
||||||
|
meta_lines.append(
|
||||||
|
f"- 원소 분포: 공기 {ed.get('air',0)}, 물 {ed.get('water',0)}, 불 {ed.get('fire',0)}, 흙 {ed.get('earth',0)}"
|
||||||
|
)
|
||||||
|
if "orientation_flow" in context_meta:
|
||||||
|
meta_lines.append(f"- 정역 흐름: {context_meta['orientation_flow']}")
|
||||||
|
meta_block = "\n".join(meta_lines) if meta_lines else "(추가 컨텍스트 없음)"
|
||||||
|
|
||||||
|
return f"""# 질문
|
||||||
|
{q}
|
||||||
|
|
||||||
|
# 카테고리
|
||||||
|
{cat}
|
||||||
|
|
||||||
|
# 스프레드
|
||||||
|
{spread_name} ({spread_count}장)
|
||||||
|
|
||||||
|
# 뽑힌 카드와 참고 카드 정보
|
||||||
|
{cards_reference}
|
||||||
|
|
||||||
|
## 추가 컨텍스트
|
||||||
|
{meta_block}
|
||||||
|
|
||||||
|
# 작업
|
||||||
|
위 정보만을 근거로 사용해, 시스템 지침의 JSON 형식으로 응답하세요.
|
||||||
|
- 각 카드의 evidence.card_meaning_used에는 위 "참고 카드 정보"에서 발췌한 키워드·의미를 그대로 인용.
|
||||||
|
- interactions는 3장 간 슈트·원소·정역방향 패턴을 분석해 최소 1개 이상 도출 (1장 스프레드면 빈 배열 허용).
|
||||||
|
- confidence는 카드 흐름의 일관성에 따라 정직하게 판정.
|
||||||
|
"""
|
||||||
36
agent-office/app/tarot/schema.py
Normal file
36
agent-office/app/tarot/schema.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Tarot 응답 스키마 검증 — 누락·빈 필드 reroll 트리거."""
|
||||||
|
|
||||||
|
VALID_CONFIDENCE = {"high", "medium", "low"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_interpretation(parsed: dict, spread_type: str) -> tuple[bool, str]:
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return False, "응답이 dict가 아님"
|
||||||
|
for k in ("summary", "cards", "interactions", "advice", "confidence"):
|
||||||
|
if k not in parsed:
|
||||||
|
return False, f"필수 필드 누락: {k}"
|
||||||
|
if parsed.get("confidence") not in VALID_CONFIDENCE:
|
||||||
|
return False, f"confidence 값 비정상: {parsed.get('confidence')}"
|
||||||
|
cards = parsed.get("cards")
|
||||||
|
if not isinstance(cards, list) or not cards:
|
||||||
|
return False, "cards가 빈 배열"
|
||||||
|
for i, c in enumerate(cards):
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
return False, f"cards[{i}] dict 아님"
|
||||||
|
for k in ("position", "card", "reversed", "interpretation", "advice", "evidence"):
|
||||||
|
if k not in c:
|
||||||
|
return False, f"cards[{i}].{k} 누락"
|
||||||
|
ev = c["evidence"]
|
||||||
|
if not isinstance(ev, dict):
|
||||||
|
return False, f"cards[{i}].evidence dict 아님"
|
||||||
|
for k in ("card_meaning_used", "position_logic", "category_lens"):
|
||||||
|
if k not in ev:
|
||||||
|
return False, f"cards[{i}].evidence.{k} 누락"
|
||||||
|
if not isinstance(ev[k], str) or not ev[k].strip():
|
||||||
|
return False, f"cards[{i}].evidence.{k} 빈 문자열"
|
||||||
|
interactions = parsed.get("interactions")
|
||||||
|
if not isinstance(interactions, list):
|
||||||
|
return False, "interactions가 list 아님"
|
||||||
|
if spread_type == "three_card" and len(interactions) == 0:
|
||||||
|
return False, "three_card는 interactions 1개 이상 필요"
|
||||||
|
return True, ""
|
||||||
19
agent-office/app/telegram/__init__.py
Normal file
19
agent-office/app/telegram/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Telegram 통합 메시지 패키지."""
|
||||||
|
from .agent_registry import AGENT_META, get_agent_meta, register_agent
|
||||||
|
from .messaging import send_agent_message, send_approval_request, send_raw
|
||||||
|
from .router import parse_command, resolve_agent_command, HELP_TEXT
|
||||||
|
from .webhook import handle_webhook, setup_webhook
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"send_agent_message",
|
||||||
|
"send_approval_request",
|
||||||
|
"send_raw",
|
||||||
|
"handle_webhook",
|
||||||
|
"setup_webhook",
|
||||||
|
"get_agent_meta",
|
||||||
|
"register_agent",
|
||||||
|
"AGENT_META",
|
||||||
|
"parse_command",
|
||||||
|
"resolve_agent_command",
|
||||||
|
"HELP_TEXT",
|
||||||
|
]
|
||||||
39
agent-office/app/telegram/agent_registry.py
Normal file
39
agent-office/app/telegram/agent_registry.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""에이전트 메타 등록소."""
|
||||||
|
|
||||||
|
AGENT_META = {
|
||||||
|
"stock": {
|
||||||
|
"display_name": "주식 트레이더",
|
||||||
|
"emoji": "📈",
|
||||||
|
"color": "#4488cc",
|
||||||
|
},
|
||||||
|
"music": {
|
||||||
|
"display_name": "음악 프로듀서",
|
||||||
|
"emoji": "🎵",
|
||||||
|
"color": "#44aa88",
|
||||||
|
},
|
||||||
|
"lotto": {
|
||||||
|
"emoji": "🎱",
|
||||||
|
"display_name": "로또 큐레이터",
|
||||||
|
},
|
||||||
|
"realestate": {
|
||||||
|
"display_name": "청약 애널리스트",
|
||||||
|
"emoji": "🏢",
|
||||||
|
"color": "#f43f5e",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_meta(agent_id: str) -> dict:
|
||||||
|
return AGENT_META.get(
|
||||||
|
agent_id,
|
||||||
|
{"display_name": agent_id, "emoji": "🤖", "color": "#888"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_agent(agent_id: str, display_name: str, emoji: str, color: str = "#888"):
|
||||||
|
"""향후 에이전트 동적 등록용"""
|
||||||
|
AGENT_META[agent_id] = {
|
||||||
|
"display_name": display_name,
|
||||||
|
"emoji": emoji,
|
||||||
|
"color": color,
|
||||||
|
}
|
||||||
18
agent-office/app/telegram/client.py
Normal file
18
agent-office/app/telegram/client.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Telegram Bot API 저수준 래퍼."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_WEBHOOK_URL
|
||||||
|
|
||||||
|
_BASE = "https://api.telegram.org/bot"
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled() -> bool:
|
||||||
|
return bool(TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_call(method: str, payload: dict) -> dict:
|
||||||
|
if not _enabled():
|
||||||
|
return {"ok": False, "description": "Telegram not configured"}
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(f"{_BASE}{TELEGRAM_BOT_TOKEN}/{method}", json=payload)
|
||||||
|
return resp.json()
|
||||||
182
agent-office/app/telegram/conversational.py
Normal file
182
agent-office/app/telegram/conversational.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""텔레그램 자연어 대화 핸들러 — Claude + 프롬프트 캐싱.
|
||||||
|
|
||||||
|
구조:
|
||||||
|
- system prompt(정적) + 최근 대화 이력 + 마지막 user turn
|
||||||
|
- system과 history 끝 블록에 cache_control=ephemeral 적용 → 5분 TTL 프롬프트 캐시
|
||||||
|
- 평가를 위해 토큰·캐시·latency를 DB에 기록
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import (
|
||||||
|
ANTHROPIC_API_KEY,
|
||||||
|
CONVERSATION_MODEL,
|
||||||
|
CONVERSATION_HISTORY_LIMIT,
|
||||||
|
CONVERSATION_RATE_PER_MIN,
|
||||||
|
TELEGRAM_CHAT_ID,
|
||||||
|
TELEGRAM_WIFE_CHAT_ID,
|
||||||
|
)
|
||||||
|
from ..db import (
|
||||||
|
save_conversation_message,
|
||||||
|
get_conversation_history,
|
||||||
|
count_recent_user_messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
API_URL = "https://api.anthropic.com/v1/messages"
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """당신은 'gahusb' 개인 웹 플랫폼의 AI 비서입니다. 텔레그램을 통해 CEO(주인)와 그의 가족과 대화합니다.
|
||||||
|
|
||||||
|
역할과 성격:
|
||||||
|
- 따뜻하지만 간결합니다. 텔레그램에서 읽기 쉽게 2~5문장 위주로 답합니다.
|
||||||
|
- 농담과 위트를 섞되 공손하게. 이모지는 상황에 맞게 1~2개만.
|
||||||
|
- 모르는 것은 솔직히 모른다고 하고, 추측은 명시합니다.
|
||||||
|
|
||||||
|
플랫폼 컨텍스트(대답에 자연스럽게 참고):
|
||||||
|
- 주식 에이전트: 뉴스 요약·시장 브리핑·포트폴리오 관리
|
||||||
|
- 음악 에이전트: AI 음악 생성(Suno/MusicGen)
|
||||||
|
- 블로그 에이전트: 키워드 리서치·포스트 생성·품질 리뷰
|
||||||
|
- 청약 에이전트: 부동산 청약 공고 수집·매칭
|
||||||
|
- 명령은 `/help`, `/agents`, `/status`, `/stock.brief` 같은 슬래시 형식이 있습니다. 사용자가 요청을 설명만 하면 해당 명령을 안내해 주세요.
|
||||||
|
|
||||||
|
응답 규칙:
|
||||||
|
- 장문 설명 금지. 스크롤을 넘기지 않을 분량.
|
||||||
|
- 에이전트 실행을 부탁받으면 지금 이 채널은 '대화'만 가능함을 알리고, 정확한 슬래시 명령을 한 줄로 제시하세요.
|
||||||
|
- HTML·마크다운 태그 없이 평문으로 답합니다."""
|
||||||
|
|
||||||
|
|
||||||
|
_rate_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def is_whitelisted(chat_id: str) -> bool:
|
||||||
|
allowed = {str(x) for x in (TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID) if x}
|
||||||
|
return str(chat_id) in allowed
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_rate_limit(chat_id: str) -> bool:
|
||||||
|
async with _rate_lock:
|
||||||
|
count = count_recent_user_messages(chat_id, seconds=60)
|
||||||
|
return count < CONVERSATION_RATE_PER_MIN
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_claude(messages: list) -> dict:
|
||||||
|
"""Anthropic Messages API 호출 (prompt caching beta)."""
|
||||||
|
headers = {
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"anthropic-beta": "prompt-caching-2024-07-31",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
# system: cache_control 적용하여 정적 프롬프트 캐싱
|
||||||
|
system_blocks = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
payload = {
|
||||||
|
"model": CONVERSATION_MODEL,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"system": system_blocks,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
r = await client.post(API_URL, headers=headers, json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_messages(history: list, user_text: str) -> list:
|
||||||
|
"""history: [{role, content(str)}, ...]. 가장 오래된 턴을 제외한 나머지 히스토리 끝 블록에
|
||||||
|
cache_control을 추가하여 누적 이력을 캐시한다."""
|
||||||
|
msgs: list = []
|
||||||
|
for h in history:
|
||||||
|
msgs.append({"role": h["role"], "content": [{"type": "text", "text": h["content"]}]})
|
||||||
|
# 히스토리 마지막 블록에 cache_control → 이전 대화를 캐시
|
||||||
|
if msgs:
|
||||||
|
last = msgs[-1]["content"][-1]
|
||||||
|
last["cache_control"] = {"type": "ephemeral"}
|
||||||
|
msgs.append({"role": "user", "content": [{"type": "text", "text": user_text}]})
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_route_to_pipeline(message: dict) -> bool:
|
||||||
|
"""파이프라인 텔레그램 메시지에 대한 reply 인 경우 youtube_publisher 로 라우팅.
|
||||||
|
|
||||||
|
Returns True if message was routed (caller should stop further processing).
|
||||||
|
"""
|
||||||
|
reply_to = message.get("reply_to_message") or {}
|
||||||
|
msg_id = reply_to.get("message_id")
|
||||||
|
if not msg_id:
|
||||||
|
return False
|
||||||
|
from .. import service_proxy
|
||||||
|
try:
|
||||||
|
link = await service_proxy.lookup_pipeline_by_msg(msg_id)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if not link:
|
||||||
|
return False
|
||||||
|
from ..agents import AGENT_REGISTRY
|
||||||
|
agent = AGENT_REGISTRY.get("youtube_publisher")
|
||||||
|
if not agent:
|
||||||
|
return False
|
||||||
|
pipeline_id = link.get("pipeline_id")
|
||||||
|
step = link.get("step")
|
||||||
|
if pipeline_id is None or not step:
|
||||||
|
return False
|
||||||
|
await agent.on_telegram_reply(pipeline_id, step, message.get("text", ""))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def respond_to_message(chat_id: str, user_text: str) -> Optional[str]:
|
||||||
|
"""자연어 메시지에 응답. 실패 시 사용자에게 돌려줄 문자열 반환(또는 None = 무시)."""
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
return None # 기능 비활성
|
||||||
|
|
||||||
|
if not is_whitelisted(chat_id):
|
||||||
|
return None # 모르는 사용자 무시
|
||||||
|
|
||||||
|
if not await _check_rate_limit(chat_id):
|
||||||
|
return "⏳ 잠시만요, 너무 빠릅니다. 분당 몇 번만 대화해 주세요."
|
||||||
|
|
||||||
|
history = get_conversation_history(chat_id, limit=CONVERSATION_HISTORY_LIMIT)
|
||||||
|
messages = _build_messages(history, user_text)
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
resp = await _call_claude(messages)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
body = e.response.text[:200] if e.response is not None else ""
|
||||||
|
return f"⚠️ Claude 호출 실패: {e.response.status_code} {body}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"⚠️ 응답 생성 중 오류: {type(e).__name__}"
|
||||||
|
latency_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
reply = "".join(
|
||||||
|
blk.get("text", "") for blk in resp.get("content", []) if blk.get("type") == "text"
|
||||||
|
).strip()
|
||||||
|
except Exception:
|
||||||
|
reply = ""
|
||||||
|
if not reply:
|
||||||
|
reply = "(빈 응답)"
|
||||||
|
|
||||||
|
usage = resp.get("usage", {}) or {}
|
||||||
|
t_in = int(usage.get("input_tokens", 0) or 0)
|
||||||
|
t_out = int(usage.get("output_tokens", 0) or 0)
|
||||||
|
c_read = int(usage.get("cache_read_input_tokens", 0) or 0)
|
||||||
|
c_write = int(usage.get("cache_creation_input_tokens", 0) or 0)
|
||||||
|
|
||||||
|
# 기록: user 먼저, assistant 나중 (순서 보존)
|
||||||
|
save_conversation_message(chat_id, "user", user_text)
|
||||||
|
save_conversation_message(
|
||||||
|
chat_id, "assistant", reply,
|
||||||
|
model=CONVERSATION_MODEL,
|
||||||
|
tokens_input=t_in, tokens_output=t_out,
|
||||||
|
cache_read=c_read, cache_write=c_write,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
)
|
||||||
|
return reply
|
||||||
51
agent-office/app/telegram/formatter.py
Normal file
51
agent-office/app/telegram/formatter.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""에이전트 메시지 포맷팅."""
|
||||||
|
from html import escape as _h
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from .agent_registry import get_agent_meta
|
||||||
|
|
||||||
|
MessageKind = Literal["report", "alert", "approval", "error", "info"]
|
||||||
|
|
||||||
|
KIND_ICONS = {
|
||||||
|
"report": "📊",
|
||||||
|
"alert": "🔔",
|
||||||
|
"approval": "✋",
|
||||||
|
"error": "⚠️",
|
||||||
|
"info": "ℹ️",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_agent_message(
|
||||||
|
agent_id: str,
|
||||||
|
kind: MessageKind,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
|
body_is_html: bool = False,
|
||||||
|
) -> str:
|
||||||
|
meta = get_agent_meta(agent_id)
|
||||||
|
icon = KIND_ICONS.get(kind, "")
|
||||||
|
header = f"{icon} <b>[{_h(meta['emoji'])} {_h(meta['display_name'])}]</b> {_h(title)}"
|
||||||
|
|
||||||
|
# Telegram 단일 메시지 4096자 제한 대응 (헤더/푸터 여유 512자 확보)
|
||||||
|
# body_is_html=True 면 호출자가 이미 HTML-safe하게 구성한 것으로 간주 (예: <a> 링크 포함)
|
||||||
|
safe_body = body if body_is_html else _h(body)
|
||||||
|
if len(safe_body) > 3500:
|
||||||
|
safe_body = safe_body[:3500] + "\n…(생략)"
|
||||||
|
|
||||||
|
lines = [header, "━" * 20, safe_body]
|
||||||
|
|
||||||
|
if metadata:
|
||||||
|
footer_parts = []
|
||||||
|
if "tokens" in metadata:
|
||||||
|
footer_parts.append(f"🧮 {metadata['tokens']:,} tokens")
|
||||||
|
if "duration_ms" in metadata:
|
||||||
|
seconds = metadata["duration_ms"] / 1000
|
||||||
|
footer_parts.append(f"⏱ {seconds:.1f}s")
|
||||||
|
if "model" in metadata:
|
||||||
|
footer_parts.append(f"🤖 {metadata['model']}")
|
||||||
|
if footer_parts:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"<i>{_h(' · '.join(footer_parts))}</i>")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
83
agent-office/app/telegram/messaging.py
Normal file
83
agent-office/app/telegram/messaging.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""고수준 메시지 전송 API."""
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..config import TELEGRAM_CHAT_ID
|
||||||
|
from ..db import save_telegram_callback
|
||||||
|
from .client import _enabled, api_call
|
||||||
|
from .formatter import MessageKind, format_agent_message
|
||||||
|
|
||||||
|
|
||||||
|
async def send_raw(
|
||||||
|
text: str,
|
||||||
|
reply_markup: Optional[dict] = None,
|
||||||
|
chat_id: Optional[str] = None,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
) -> dict:
|
||||||
|
"""가장 저수준. 원문 텍스트 그대로 전송. chat_id 생략 시 기본 TELEGRAM_CHAT_ID로.
|
||||||
|
|
||||||
|
parse_mode: 기본 'HTML'. MarkdownV2 페이로드(예: 스크리너) 전송 시 명시 지정.
|
||||||
|
"""
|
||||||
|
if not _enabled():
|
||||||
|
return {"ok": False, "message_id": None}
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id or TELEGRAM_CHAT_ID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": parse_mode,
|
||||||
|
}
|
||||||
|
if reply_markup:
|
||||||
|
payload["reply_markup"] = reply_markup
|
||||||
|
result = await api_call("sendMessage", payload)
|
||||||
|
ok = result.get("ok", False)
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"message_id": result.get("result", {}).get("message_id") if ok else None,
|
||||||
|
"description": result.get("description") if not ok else None,
|
||||||
|
"error_code": result.get("error_code") if not ok else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_agent_message(
|
||||||
|
agent_id: str,
|
||||||
|
kind: MessageKind,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
task_id: Optional[str] = None,
|
||||||
|
actions: Optional[list] = None,
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
|
body_is_html: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""통합 에이전트 메시지 API. 모든 에이전트가 이걸 씀.
|
||||||
|
|
||||||
|
body_is_html=True: 호출자가 이미 HTML-safe 포맷(링크 <a> 등) 구성한 경우.
|
||||||
|
"""
|
||||||
|
text = format_agent_message(agent_id, kind, title, body, metadata, body_is_html=body_is_html)
|
||||||
|
reply_markup = None
|
||||||
|
if actions:
|
||||||
|
buttons = []
|
||||||
|
for action in actions:
|
||||||
|
cb_id = f"{action['action']}_{uuid.uuid4().hex[:8]}"
|
||||||
|
save_telegram_callback(cb_id, task_id or "", agent_id)
|
||||||
|
buttons.append({"text": action["label"], "callback_data": cb_id})
|
||||||
|
reply_markup = {"inline_keyboard": [buttons]}
|
||||||
|
return await send_raw(text, reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_approval_request(
|
||||||
|
agent_id: str,
|
||||||
|
task_id: str,
|
||||||
|
title: str,
|
||||||
|
detail: str,
|
||||||
|
) -> dict:
|
||||||
|
"""승인/거절 단축 헬퍼."""
|
||||||
|
return await send_agent_message(
|
||||||
|
agent_id=agent_id,
|
||||||
|
kind="approval",
|
||||||
|
title=title,
|
||||||
|
body=detail,
|
||||||
|
task_id=task_id,
|
||||||
|
actions=[
|
||||||
|
{"label": "✅ 승인", "action": "approve"},
|
||||||
|
{"label": "❌ 거절", "action": "reject"},
|
||||||
|
],
|
||||||
|
)
|
||||||
93
agent-office/app/telegram/realestate_message.py
Normal file
93
agent-office/app/telegram/realestate_message.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""청약 매칭 알림 — 텔레그램 메시지 포맷터 + 인라인 키보드 빌더."""
|
||||||
|
import os
|
||||||
|
from html import escape as _h
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
DASHBOARD_URL = os.getenv("REALESTATE_DASHBOARD_URL", "https://example.com/realestate")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_one_compact(m: dict) -> str:
|
||||||
|
score = m.get("match_score", 0)
|
||||||
|
name = _h(m.get("house_nm") or "(제목 없음)")
|
||||||
|
district = m.get("district") or ""
|
||||||
|
region = m.get("region_name") or ""
|
||||||
|
where = f"{region.split()[0] if region else ''} {district}".strip() or "위치 미상"
|
||||||
|
rstart = m.get("receipt_start") or ""
|
||||||
|
rend = m.get("receipt_end") or ""
|
||||||
|
return (
|
||||||
|
f"⭐ {score}점 — <b>{name}</b>\n"
|
||||||
|
f"📍 {_h(where)} 📅 {_h(rstart)} ~ {_h(rend)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_one_full(m: dict) -> str:
|
||||||
|
score = m.get("match_score", 0)
|
||||||
|
name = _h(m.get("house_nm") or "(제목 없음)")
|
||||||
|
district = m.get("district") or ""
|
||||||
|
region = m.get("region_name") or ""
|
||||||
|
flags = []
|
||||||
|
if m.get("is_speculative_area") == "Y":
|
||||||
|
flags.append("투기과열")
|
||||||
|
if m.get("is_price_cap") == "Y":
|
||||||
|
flags.append("분양가상한제")
|
||||||
|
flag_str = f" ({', '.join(flags)})" if flags else ""
|
||||||
|
|
||||||
|
rstart = m.get("receipt_start") or ""
|
||||||
|
rend = m.get("receipt_end") or ""
|
||||||
|
elig = m.get("eligible_types") or []
|
||||||
|
reasons = m.get("match_reasons") or []
|
||||||
|
|
||||||
|
where = f"{region.split()[0] if region else ''} {district}".strip() or "위치 미상"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"⭐ {score}점 — <b>{name}</b>",
|
||||||
|
f"📍 {_h(where)}{_h(flag_str)}",
|
||||||
|
f"📅 청약 {_h(rstart)} ~ {_h(rend)}",
|
||||||
|
]
|
||||||
|
if elig:
|
||||||
|
lines.append(f"✓ 자격: {_h(', '.join(elig))}")
|
||||||
|
if reasons:
|
||||||
|
lines.append(f"💡 {_h(' / '.join(reasons[:4]))}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_realestate_matches(matches: list[dict]) -> str:
|
||||||
|
"""매칭 목록을 텔레그램 HTML 메시지로 변환.
|
||||||
|
1~2건은 풀 카드, 3건 이상은 묶음 카드(상위 5건).
|
||||||
|
"""
|
||||||
|
if not matches:
|
||||||
|
return "🏢 새 청약 매칭이 없습니다."
|
||||||
|
|
||||||
|
if len(matches) <= 2:
|
||||||
|
body = "\n\n".join(_format_one_full(m) for m in matches)
|
||||||
|
return f"🏢 <b>새 청약 매칭 {len(matches)}건</b>\n━━━━━━━━━━\n\n{body}"
|
||||||
|
|
||||||
|
top = matches[:5]
|
||||||
|
body = "\n\n".join(_format_one_compact(m) for m in top)
|
||||||
|
suffix = f"\n\n…외 {len(matches) - 5}건" if len(matches) > 5 else ""
|
||||||
|
return f"🏢 <b>새 청약 매칭 {len(matches)}건</b>\n━━━━━━━━━━\n\n{body}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_match_keyboard(matches: list[dict]) -> Optional[dict]:
|
||||||
|
"""1~2건: 매치별 [북마크][공고 보기] 행. 3건 이상: [전체 보기] 단일 행."""
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(matches) <= 2:
|
||||||
|
rows = []
|
||||||
|
for m in matches:
|
||||||
|
buttons = [{
|
||||||
|
"text": "🔖 북마크",
|
||||||
|
"callback_data": f"realestate_bookmark_{m['id']}",
|
||||||
|
}]
|
||||||
|
url = m.get("pblanc_url")
|
||||||
|
if url:
|
||||||
|
buttons.append({"text": "📄 공고 보기", "url": url})
|
||||||
|
rows.append(buttons)
|
||||||
|
return {"inline_keyboard": rows}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"inline_keyboard": [[
|
||||||
|
{"text": "📋 전체 보기", "url": DASHBOARD_URL},
|
||||||
|
]],
|
||||||
|
}
|
||||||
95
agent-office/app/telegram/router.py
Normal file
95
agent-office/app/telegram/router.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""텔레그램 메시지 명령 → 에이전트 라우팅.
|
||||||
|
새 명령을 추가하려면 AGENT_COMMAND_MAP에 등록만 하면 됨."""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def parse_command(text: str) -> Optional[tuple]:
|
||||||
|
"""슬래시 명령 파싱.
|
||||||
|
|
||||||
|
반환: (agent_id_or_None, command, args_list) 또는 None
|
||||||
|
|
||||||
|
예시:
|
||||||
|
/stock news -> ("stock", "news", [])
|
||||||
|
/status -> (None, "status", [])
|
||||||
|
/music compose 잔잔한 피아노 -> ("music", "compose", ["잔잔한 피아노"])
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
text = text.strip()
|
||||||
|
if not text.startswith("/"):
|
||||||
|
return None
|
||||||
|
parts = text[1:].split(maxsplit=2)
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
first = parts[0].lower()
|
||||||
|
|
||||||
|
# 전역 명령
|
||||||
|
if first in ("status", "agents", "help"):
|
||||||
|
return (None, first, parts[1:] if len(parts) > 1 else [])
|
||||||
|
|
||||||
|
# 에이전트 명령: /<agent> <command> [args...]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
agent_id = first
|
||||||
|
command = parts[1].lower()
|
||||||
|
args = [parts[2]] if len(parts) > 2 else []
|
||||||
|
return (agent_id, command, args)
|
||||||
|
|
||||||
|
|
||||||
|
# 에이전트별 텔레그램 → 내부 command 매핑
|
||||||
|
# 텔레그램에서 친숙한 이름 -> (실제 on_command의 command, 기본 params)
|
||||||
|
AGENT_COMMAND_MAP = {
|
||||||
|
"stock": {
|
||||||
|
"news": ("fetch_news", {}),
|
||||||
|
"alerts": ("list_alerts", {}),
|
||||||
|
"test": ("test_telegram", {}),
|
||||||
|
},
|
||||||
|
"music": {
|
||||||
|
"credits": ("credits", {}),
|
||||||
|
# compose는 인자 필요 — 아래 특수 케이스에서 처리
|
||||||
|
},
|
||||||
|
"realestate": {
|
||||||
|
"matches": ("fetch_matches", {}),
|
||||||
|
"dashboard": ("dashboard", {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_agent_command(agent_id: str, command: str, args: list) -> Optional[tuple]:
|
||||||
|
"""(internal_command, params) 반환. 매핑 없으면 None."""
|
||||||
|
mapping = AGENT_COMMAND_MAP.get(agent_id, {}).get(command)
|
||||||
|
if mapping is None:
|
||||||
|
# 특수 케이스: music compose <prompt>
|
||||||
|
if agent_id == "music" and command == "compose" and args:
|
||||||
|
return ("compose", {"prompt": " ".join(args)})
|
||||||
|
return None
|
||||||
|
internal_cmd, base_params = mapping
|
||||||
|
params = dict(base_params)
|
||||||
|
if args:
|
||||||
|
# args가 있으면 첫 번째(합쳐진 나머지)를 message로 자동 주입
|
||||||
|
params["message"] = " ".join(args)
|
||||||
|
return (internal_cmd, params)
|
||||||
|
|
||||||
|
|
||||||
|
HELP_TEXT = """<b>🤖 Agent Office 텔레그램 명령</b>
|
||||||
|
|
||||||
|
<b>전역</b>
|
||||||
|
/status — 모든 에이전트 상태
|
||||||
|
/agents — 에이전트 목록
|
||||||
|
/help — 이 도움말
|
||||||
|
|
||||||
|
<b>📈 주식 트레이더</b>
|
||||||
|
/stock news — 뉴스 AI 요약 실행
|
||||||
|
/stock alerts — 알람 목록
|
||||||
|
/stock test — 텔레그램 테스트
|
||||||
|
|
||||||
|
<b>🎵 음악 프로듀서</b>
|
||||||
|
/music credits — Suno 크레딧 조회
|
||||||
|
/music compose <프롬프트> — 작곡 시작
|
||||||
|
|
||||||
|
<b>🏢 청약 애널리스트</b>
|
||||||
|
/realestate matches — 신규 매칭 조회 후 알림 전송
|
||||||
|
/realestate dashboard — 청약 현황 요약
|
||||||
|
"""
|
||||||
239
agent-office/app/telegram/webhook.py
Normal file
239
agent-office/app/telegram/webhook.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""텔레그램 Webhook 이벤트 처리."""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..db import get_telegram_callback, mark_telegram_responded
|
||||||
|
from .client import _enabled, api_call
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_webhook(data: dict, agent_dispatcher=None) -> Optional[dict]:
|
||||||
|
"""텔레그램에서 들어오는 이벤트 처리.
|
||||||
|
|
||||||
|
- callback_query(인라인 버튼)는 항상 처리 → 승인/거절 dict 반환
|
||||||
|
- message(텍스트 슬래시 명령)는 `agent_dispatcher`가 주입된 경우에만 처리
|
||||||
|
|
||||||
|
agent_dispatcher: async (agent_id, command, params) -> dict
|
||||||
|
- agent_id == "__global__", command == "status" 특수 케이스는
|
||||||
|
{agent_id: {state, detail}} dict를 반환해야 함.
|
||||||
|
"""
|
||||||
|
callback_query = data.get("callback_query")
|
||||||
|
if callback_query:
|
||||||
|
return await _handle_callback(callback_query)
|
||||||
|
|
||||||
|
message = data.get("message")
|
||||||
|
if message:
|
||||||
|
chat = message.get("chat", {})
|
||||||
|
print(f"[TG-WEBHOOK] chat.id={chat.get('id')} type={chat.get('type')} text={message.get('text')!r}", flush=True)
|
||||||
|
if message and message.get("text") and agent_dispatcher is not None:
|
||||||
|
return await _handle_message(message, agent_dispatcher)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_callback(callback_query: dict) -> Optional[dict]:
|
||||||
|
"""승인/거절 및 realestate 북마크 콜백 처리."""
|
||||||
|
callback_id = callback_query.get("data", "")
|
||||||
|
|
||||||
|
# realestate 북마크 토글 콜백 — DB 조회 없이 직접 처리
|
||||||
|
if callback_id.startswith("realestate_bookmark_"):
|
||||||
|
return await _handle_realestate_bookmark(callback_query, callback_id)
|
||||||
|
|
||||||
|
if callback_id.startswith("render_"):
|
||||||
|
return await _handle_insta_render(callback_query, callback_id)
|
||||||
|
|
||||||
|
cb = get_telegram_callback(callback_id)
|
||||||
|
if not cb:
|
||||||
|
return None
|
||||||
|
|
||||||
|
action = callback_id.split("_")[0]
|
||||||
|
mark_telegram_responded(callback_id, action)
|
||||||
|
|
||||||
|
feedback_text = {
|
||||||
|
"approve": "승인됨 ✅",
|
||||||
|
"reject": "거절됨 ❌",
|
||||||
|
}.get(action, f"처리됨: {action}")
|
||||||
|
|
||||||
|
await api_call(
|
||||||
|
"answerCallbackQuery",
|
||||||
|
{
|
||||||
|
"callback_query_id": callback_query["id"],
|
||||||
|
"text": feedback_text,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"task_id": cb["task_id"],
|
||||||
|
"agent_id": cb["agent_id"],
|
||||||
|
"action": action,
|
||||||
|
"approved": action == "approve",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_realestate_bookmark(callback_query: dict, callback_id: str) -> dict:
|
||||||
|
"""realestate_bookmark_{announcement_id} 콜백 처리."""
|
||||||
|
from .. import service_proxy
|
||||||
|
from .messaging import send_raw
|
||||||
|
|
||||||
|
# answerCallbackQuery 먼저 — 텔레그램 로딩 스피너 해제
|
||||||
|
await api_call(
|
||||||
|
"answerCallbackQuery",
|
||||||
|
{"callback_query_id": callback_query["id"], "text": "처리 중..."},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ann_id = int(callback_id.removeprefix("realestate_bookmark_"))
|
||||||
|
except ValueError:
|
||||||
|
await send_raw("⚠️ 잘못된 북마크 콜백 데이터")
|
||||||
|
return {"ok": False, "error": "invalid_callback_data"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await service_proxy.realestate_bookmark_toggle(ann_id)
|
||||||
|
is_on = result.get("is_bookmarked")
|
||||||
|
if is_on == 1:
|
||||||
|
await send_raw(f"🔖 북마크 추가 완료 (#{ann_id})")
|
||||||
|
elif is_on == 0:
|
||||||
|
await send_raw(f"🔖 북마크 해제 완료 (#{ann_id})")
|
||||||
|
else:
|
||||||
|
await send_raw(f"🔖 북마크 토글 완료 (#{ann_id})")
|
||||||
|
return {"ok": True, "announcement_id": ann_id}
|
||||||
|
except Exception as e:
|
||||||
|
await send_raw(f"⚠️ 북마크 처리 실패: {e}")
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_insta_render(callback_query: dict, callback_id: str) -> dict:
|
||||||
|
"""render_{keyword_id} 콜백 → InstaAgent.on_callback('render', ...).
|
||||||
|
|
||||||
|
텔레그램 인라인 버튼이 보낸 callback_data가 `render_<keyword_id>` 형식.
|
||||||
|
InstaAgent._push_keyword_candidates가 callback_data를 그대로 박아 보내며,
|
||||||
|
별도 DB lookup 없이 keyword_id를 파싱해 dispatch한다."""
|
||||||
|
from .messaging import send_raw
|
||||||
|
from ..agents import AGENT_REGISTRY
|
||||||
|
|
||||||
|
await api_call(
|
||||||
|
"answerCallbackQuery",
|
||||||
|
{"callback_query_id": callback_query["id"], "text": "카드 생성 시작"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
keyword_id = int(callback_id.removeprefix("render_"))
|
||||||
|
except ValueError:
|
||||||
|
await send_raw("⚠️ 잘못된 render 콜백 데이터")
|
||||||
|
return {"ok": False, "error": "invalid_callback_data"}
|
||||||
|
|
||||||
|
agent = AGENT_REGISTRY.get("insta")
|
||||||
|
if not agent:
|
||||||
|
await send_raw("⚠️ insta agent 미등록")
|
||||||
|
return {"ok": False, "error": "agent_missing"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await agent.on_callback("render", {"keyword_id": keyword_id})
|
||||||
|
except Exception as e:
|
||||||
|
await send_raw(f"⚠️ 카드 생성 실패: {e}")
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_message(message: dict, agent_dispatcher) -> Optional[dict]:
|
||||||
|
"""슬래시 명령 메시지 처리."""
|
||||||
|
from .router import parse_command, resolve_agent_command, HELP_TEXT
|
||||||
|
from .messaging import send_raw, send_agent_message
|
||||||
|
from .agent_registry import AGENT_META
|
||||||
|
from .conversational import maybe_route_to_pipeline
|
||||||
|
|
||||||
|
# 파이프라인 메시지에 대한 reply라면 youtube_publisher 로 라우팅
|
||||||
|
if await maybe_route_to_pipeline(message):
|
||||||
|
return {"handled": "pipeline_reply"}
|
||||||
|
|
||||||
|
text = message.get("text", "")
|
||||||
|
parsed = parse_command(text)
|
||||||
|
if not parsed:
|
||||||
|
# 슬래시 명령이 아니면 자연어 대화로 라우팅
|
||||||
|
chat_id = str(message.get("chat", {}).get("id", ""))
|
||||||
|
if not chat_id:
|
||||||
|
return None
|
||||||
|
from .conversational import respond_to_message
|
||||||
|
reply = await respond_to_message(chat_id, text)
|
||||||
|
if reply:
|
||||||
|
import html as _html
|
||||||
|
await send_raw(_html.escape(reply), chat_id=chat_id)
|
||||||
|
return {"handled": "chat"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
agent_id, command, args = parsed
|
||||||
|
|
||||||
|
# 전역 명령
|
||||||
|
if agent_id is None:
|
||||||
|
if command == "help":
|
||||||
|
await send_raw(HELP_TEXT)
|
||||||
|
return {"handled": "help"}
|
||||||
|
|
||||||
|
if command == "agents":
|
||||||
|
lines = ["<b>📋 등록된 에이전트</b>", ""]
|
||||||
|
for aid, meta in AGENT_META.items():
|
||||||
|
lines.append(
|
||||||
|
f"{meta['emoji']} <b>{meta['display_name']}</b> <code>/{aid}</code>"
|
||||||
|
)
|
||||||
|
await send_raw("\n".join(lines))
|
||||||
|
return {"handled": "agents"}
|
||||||
|
|
||||||
|
if command == "status":
|
||||||
|
try:
|
||||||
|
result = await agent_dispatcher("__global__", "status", {})
|
||||||
|
body_lines = []
|
||||||
|
if isinstance(result, dict):
|
||||||
|
for aid, info in result.items():
|
||||||
|
meta = AGENT_META.get(
|
||||||
|
aid, {"emoji": "🤖", "display_name": aid}
|
||||||
|
)
|
||||||
|
state = info.get("state", "unknown") if isinstance(info, dict) else "unknown"
|
||||||
|
body_lines.append(
|
||||||
|
f"{meta['emoji']} <b>{meta['display_name']}</b>: <code>{state}</code>"
|
||||||
|
)
|
||||||
|
detail = info.get("detail") if isinstance(info, dict) else None
|
||||||
|
if detail:
|
||||||
|
body_lines.append(f" └ {detail}")
|
||||||
|
await send_raw("<b>📊 전체 상태</b>\n\n" + "\n".join(body_lines))
|
||||||
|
except Exception as e:
|
||||||
|
await send_raw(f"⚠️ 상태 조회 실패: {e}")
|
||||||
|
return {"handled": "status"}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 에이전트 명령
|
||||||
|
if agent_id not in AGENT_META:
|
||||||
|
await send_raw(
|
||||||
|
f"⚠️ 알 수 없는 에이전트: <code>{agent_id}</code>\n/help 로 사용 가능한 명령 확인"
|
||||||
|
)
|
||||||
|
return {"handled": "unknown_agent"}
|
||||||
|
|
||||||
|
resolved = resolve_agent_command(agent_id, command, args)
|
||||||
|
if resolved is None:
|
||||||
|
await send_raw(
|
||||||
|
f"⚠️ <code>{agent_id}</code>에서 <code>{command}</code> 명령은 지원하지 않습니다."
|
||||||
|
)
|
||||||
|
return {"handled": "unknown_command"}
|
||||||
|
|
||||||
|
internal_cmd, params = resolved
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await agent_dispatcher(agent_id, internal_cmd, params)
|
||||||
|
ok = result.get("ok", False) if isinstance(result, dict) else False
|
||||||
|
msg = result.get("message", "") if isinstance(result, dict) else str(result)
|
||||||
|
|
||||||
|
await send_agent_message(
|
||||||
|
agent_id=agent_id,
|
||||||
|
kind="info" if ok else "error",
|
||||||
|
title=f"{internal_cmd} 실행 결과",
|
||||||
|
body=msg or str(result),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await send_raw(f"⚠️ 명령 실행 실패: {e}")
|
||||||
|
|
||||||
|
return {"handled": "command", "agent_id": agent_id, "command": internal_cmd}
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_webhook() -> dict:
|
||||||
|
from ..config import TELEGRAM_WEBHOOK_URL
|
||||||
|
|
||||||
|
if not _enabled() or not TELEGRAM_WEBHOOK_URL:
|
||||||
|
return {"ok": False, "description": "Webhook URL not configured"}
|
||||||
|
return await api_call("setWebhook", {"url": TELEGRAM_WEBHOOK_URL})
|
||||||
27
agent-office/app/telegram_bot.py
Normal file
27
agent-office/app/telegram_bot.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Deprecated: app.telegram 패키지 사용 권장. 하위 호환용 re-export."""
|
||||||
|
from .telegram import handle_webhook, send_approval_request, send_raw, setup_webhook
|
||||||
|
from .telegram.messaging import send_agent_message
|
||||||
|
|
||||||
|
|
||||||
|
# 기존 호출자가 쓰던 이름들
|
||||||
|
async def send_message(text: str, reply_markup: dict = None) -> dict:
|
||||||
|
return await send_raw(text, reply_markup)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_stock_summary(summary: str) -> dict:
|
||||||
|
return await send_raw(summary)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_task_result(agent_id: str, title: str, result: str) -> dict:
|
||||||
|
return await send_agent_message(agent_id, "report", title, result)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"send_message",
|
||||||
|
"send_stock_summary",
|
||||||
|
"send_task_result",
|
||||||
|
"send_approval_request",
|
||||||
|
"send_agent_message",
|
||||||
|
"handle_webhook",
|
||||||
|
"setup_webhook",
|
||||||
|
]
|
||||||
110
agent-office/app/test_db.py
Normal file
110
agent-office/app/test_db.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Override DB_PATH before importing db
|
||||||
|
_tmp = tempfile.mktemp(suffix=".db")
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _tmp
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
from app.db import (
|
||||||
|
init_db, get_all_agents, get_agent_config, update_agent_config,
|
||||||
|
create_task, update_task_status, approve_task, get_task, get_agent_tasks,
|
||||||
|
get_pending_approvals, add_log, get_logs,
|
||||||
|
save_telegram_callback, get_telegram_callback, mark_telegram_responded,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_and_seed():
|
||||||
|
init_db()
|
||||||
|
agents = get_all_agents()
|
||||||
|
assert len(agents) == 2, f"Expected 2 agents, got {len(agents)}"
|
||||||
|
ids = {a["agent_id"] for a in agents}
|
||||||
|
assert ids == {"stock", "music"}, f"Unexpected agent ids: {ids}"
|
||||||
|
print(" [PASS] test_init_and_seed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_config_update():
|
||||||
|
init_db()
|
||||||
|
update_agent_config("stock", custom_config={"watch": ["AAPL"]})
|
||||||
|
cfg = get_agent_config("stock")
|
||||||
|
assert cfg["custom_config"] == {"watch": ["AAPL"]}, f"Unexpected config: {cfg['custom_config']}"
|
||||||
|
print(" [PASS] test_agent_config_update")
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_lifecycle():
|
||||||
|
init_db()
|
||||||
|
# Create task with approval
|
||||||
|
tid = create_task("music", "compose", {"prompt": "test"}, requires_approval=True)
|
||||||
|
task = get_task(tid)
|
||||||
|
assert task["status"] == "pending", f"Expected pending, got {task['status']}"
|
||||||
|
assert task["requires_approval"] is True
|
||||||
|
|
||||||
|
# Approve
|
||||||
|
approve_task(tid, via="telegram")
|
||||||
|
task = get_task(tid)
|
||||||
|
assert task["status"] == "approved", f"Expected approved, got {task['status']}"
|
||||||
|
assert task["approved_via"] == "telegram"
|
||||||
|
|
||||||
|
# Complete
|
||||||
|
update_task_status(tid, "succeeded", {"url": "/media/music/test.mp3"})
|
||||||
|
task = get_task(tid)
|
||||||
|
assert task["status"] == "succeeded", f"Expected succeeded, got {task['status']}"
|
||||||
|
assert task["result_data"]["url"] == "/media/music/test.mp3"
|
||||||
|
print(" [PASS] test_task_lifecycle")
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_no_approval():
|
||||||
|
init_db()
|
||||||
|
tid = create_task("stock", "news_summary", {"limit": 10})
|
||||||
|
task = get_task(tid)
|
||||||
|
assert task["status"] == "working", f"Expected working, got {task['status']}"
|
||||||
|
print(" [PASS] test_task_no_approval")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_approvals():
|
||||||
|
init_db()
|
||||||
|
create_task("music", "compose", {"prompt": "a"}, requires_approval=True)
|
||||||
|
create_task("music", "compose", {"prompt": "b"}, requires_approval=True)
|
||||||
|
create_task("stock", "news_summary", {})
|
||||||
|
pending = get_pending_approvals()
|
||||||
|
assert len(pending) == 2, f"Expected 2 pending, got {len(pending)}"
|
||||||
|
print(" [PASS] test_pending_approvals")
|
||||||
|
|
||||||
|
|
||||||
|
def test_logs():
|
||||||
|
init_db()
|
||||||
|
add_log("stock", "News fetched", "info", "task-1")
|
||||||
|
add_log("stock", "API error", "error")
|
||||||
|
logs = get_logs("stock")
|
||||||
|
assert len(logs) == 2, f"Expected 2 logs, got {len(logs)}"
|
||||||
|
assert logs[0]["level"] == "error", f"Expected error first (DESC), got {logs[0]['level']}"
|
||||||
|
print(" [PASS] test_logs")
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_state():
|
||||||
|
init_db()
|
||||||
|
save_telegram_callback("cb-1", "task-1", "music")
|
||||||
|
cb = get_telegram_callback("cb-1")
|
||||||
|
assert cb["task_id"] == "task-1"
|
||||||
|
mark_telegram_responded("cb-1", "approve")
|
||||||
|
cb = get_telegram_callback("cb-1")
|
||||||
|
assert cb is None, f"Expected None after responded=1, got {cb}"
|
||||||
|
print(" [PASS] test_telegram_state")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_init_and_seed()
|
||||||
|
test_agent_config_update()
|
||||||
|
test_task_lifecycle()
|
||||||
|
test_task_no_approval()
|
||||||
|
test_pending_approvals()
|
||||||
|
test_logs()
|
||||||
|
test_telegram_state()
|
||||||
|
print("All DB tests passed!")
|
||||||
|
# Cleanup temp DB (best-effort; WAL mode may keep files open on Windows)
|
||||||
|
for ext in ("", "-wal", "-shm"):
|
||||||
|
try:
|
||||||
|
os.unlink(_tmp + ext)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
55
agent-office/app/websocket_manager.py
Normal file
55
agent-office/app/websocket_manager.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, Set
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
class WebSocketManager:
|
||||||
|
def __init__(self):
|
||||||
|
self._connections: Set[WebSocket] = set()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def connect(self, ws: WebSocket) -> None:
|
||||||
|
await ws.accept()
|
||||||
|
async with self._lock:
|
||||||
|
self._connections.add(ws)
|
||||||
|
|
||||||
|
async def disconnect(self, ws: WebSocket) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
self._connections.discard(ws)
|
||||||
|
|
||||||
|
async def broadcast(self, message: Dict[str, Any]) -> None:
|
||||||
|
payload = json.dumps(message, ensure_ascii=False)
|
||||||
|
async with self._lock:
|
||||||
|
dead = set()
|
||||||
|
for ws in self._connections:
|
||||||
|
try:
|
||||||
|
await ws.send_text(payload)
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
self._connections -= dead
|
||||||
|
|
||||||
|
async def send_agent_state(self, agent_id: str, state: str, detail: str = "", task_id: str = None) -> None:
|
||||||
|
msg = {"type": "agent_state", "agent": agent_id, "state": state, "detail": detail}
|
||||||
|
if task_id:
|
||||||
|
msg["task_id"] = task_id
|
||||||
|
await self.broadcast(msg)
|
||||||
|
|
||||||
|
async def send_task_complete(self, agent_id: str, task_id: str, result: dict) -> None:
|
||||||
|
await self.broadcast({
|
||||||
|
"type": "task_complete", "agent": agent_id,
|
||||||
|
"task_id": task_id, "result": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def send_agent_move(self, agent_id: str, target: str) -> None:
|
||||||
|
await self.broadcast({"type": "agent_move", "agent": agent_id, "target": target})
|
||||||
|
|
||||||
|
async def send_notification(self, agent_id: str, event: str, task_id: str = None, message: str = "") -> None:
|
||||||
|
await self.broadcast({
|
||||||
|
"type": "notification",
|
||||||
|
"agent": agent_id,
|
||||||
|
"event": event,
|
||||||
|
"task_id": task_id,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
|
||||||
|
ws_manager = WebSocketManager()
|
||||||
142
agent-office/app/youtube_researcher.py
Normal file
142
agent-office/app/youtube_researcher.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import asyncio
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
YOUTUBE_DATA_API_KEY = os.getenv("YOUTUBE_DATA_API_KEY", "")
|
||||||
|
MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://music-lab:8000")
|
||||||
|
TARGET_COUNTRIES = ["BR", "ID", "MX", "US", "KR"]
|
||||||
|
TREND_KEYWORDS = ["lofi music", "phonk", "ambient music", "chill beats", "study music"]
|
||||||
|
YOUTUBE_MUSIC_CAT = "10"
|
||||||
|
|
||||||
|
GENRE_TAGS = {
|
||||||
|
"lo-fi": ["lofi", "lo-fi", "lo fi", "chill", "study"],
|
||||||
|
"phonk": ["phonk", "drift", "memphis"],
|
||||||
|
"ambient": ["ambient", "relaxing", "meditation"],
|
||||||
|
"pop": ["pop", "kpop", "k-pop"],
|
||||||
|
"funk": ["funk", "baile funk"],
|
||||||
|
"latin": ["latin", "reggaeton", "sertanejo"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tags_to_genre(tags: list) -> str:
|
||||||
|
joined = " ".join(t.lower() for t in tags)
|
||||||
|
for genre, kws in GENRE_TAGS.items():
|
||||||
|
if any(kw in joined for kw in kws):
|
||||||
|
return genre
|
||||||
|
return "general"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_youtube_trending(country: str, max_results: int = 50) -> List[Dict[str, Any]]:
|
||||||
|
"""YouTube Data API v3 — 국가별 트렌딩 음악 영상 (categoryId=10)."""
|
||||||
|
if not YOUTUBE_DATA_API_KEY:
|
||||||
|
return []
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
"https://www.googleapis.com/youtube/v3/videos",
|
||||||
|
params={
|
||||||
|
"part": "snippet,statistics",
|
||||||
|
"chart": "mostPopular",
|
||||||
|
"regionCode": country,
|
||||||
|
"videoCategoryId": YOUTUBE_MUSIC_CAT,
|
||||||
|
"maxResults": max_results,
|
||||||
|
"key": YOUTUBE_DATA_API_KEY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return []
|
||||||
|
items = resp.json().get("items", [])
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
snippet = item.get("snippet", {})
|
||||||
|
stats = item.get("statistics", {})
|
||||||
|
genre = _tags_to_genre(snippet.get("tags") or [])
|
||||||
|
results.append({
|
||||||
|
"source": "youtube",
|
||||||
|
"country": country,
|
||||||
|
"genre": genre,
|
||||||
|
"keyword": snippet.get("title", "")[:100],
|
||||||
|
"score": round(1.0 - i / max_results, 3),
|
||||||
|
"rank": i + 1,
|
||||||
|
"metadata": {
|
||||||
|
"video_id": item["id"],
|
||||||
|
"view_count": int(stats.get("viewCount", 0)),
|
||||||
|
"channel": snippet.get("channelTitle", ""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_google_trends(keywords: List[str], countries: List[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""pytrends — 키워드별 Google 관심도 (sync → threadpool)."""
|
||||||
|
try:
|
||||||
|
from pytrends.request import TrendReq
|
||||||
|
except ImportError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _sync_fetch(kw: str) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
pt = TrendReq(hl="en-US", tz=0, timeout=(5, 15))
|
||||||
|
pt.build_payload([kw], timeframe="now 7-d")
|
||||||
|
df = pt.interest_over_time()
|
||||||
|
if df.empty or kw not in df.columns:
|
||||||
|
return []
|
||||||
|
score = round(float(df[kw].mean()) / 100.0, 3)
|
||||||
|
return [
|
||||||
|
{"source": "google_trends", "country": c, "genre": "",
|
||||||
|
"keyword": kw, "score": score, "rank": None, "metadata": {}}
|
||||||
|
for c in countries
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
results = []
|
||||||
|
for kw in keywords[:5]:
|
||||||
|
rows = await loop.run_in_executor(None, _sync_fetch, kw)
|
||||||
|
results.extend(rows)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_billboard_top20() -> List[Dict[str, Any]]:
|
||||||
|
"""Billboard Hot 100 스크래핑 — 상위 20위."""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=10.0,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
|
||||||
|
follow_redirects=True,
|
||||||
|
) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.get("https://www.billboard.com/charts/hot-100/")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return []
|
||||||
|
titles = re.findall(
|
||||||
|
r'class="c-title[^"]*"[^>]*>\s*([^<\n]{3,80})\s*<', resp.text
|
||||||
|
)[:20]
|
||||||
|
return [
|
||||||
|
{"source": "billboard", "country": "US", "genre": "pop",
|
||||||
|
"keyword": t.strip(), "score": round(1.0 - i / 20, 3),
|
||||||
|
"rank": i + 1, "metadata": {}}
|
||||||
|
for i, t in enumerate(titles) if t.strip()
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def push_to_music_lab(trends: List[Dict[str, Any]], report_date: str) -> bool:
|
||||||
|
"""수집한 트렌드를 music-lab /api/music/market/ingest로 push."""
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{MUSIC_LAB_URL}/api/music/market/ingest",
|
||||||
|
json={"trends": trends, "report_date": report_date},
|
||||||
|
)
|
||||||
|
return resp.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
9
agent-office/requirements.txt
Normal file
9
agent-office/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
apscheduler==3.10.4
|
||||||
|
websockets>=12.0
|
||||||
|
httpx>=0.27
|
||||||
|
respx>=0.21
|
||||||
|
pytest-asyncio>=0.23
|
||||||
|
google-api-python-client>=2.100.0
|
||||||
|
pytrends>=4.9.2
|
||||||
48
agent-office/tests/test_classify_intent.py
Normal file
48
agent-office/tests/test_classify_intent.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from httpx import Response
|
||||||
|
from app.agents import classify_intent as ci
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_approve_no_llm(monkeypatch):
|
||||||
|
# Patch _llm_classify so we can assert it wasn't called
|
||||||
|
called = {"n": 0}
|
||||||
|
def fake(text):
|
||||||
|
called["n"] += 1
|
||||||
|
return ("unclear", None)
|
||||||
|
monkeypatch.setattr(ci, "_llm_classify", fake)
|
||||||
|
assert ci.classify("승인") == ("approve", None)
|
||||||
|
assert ci.classify("OK") == ("approve", None)
|
||||||
|
assert ci.classify("진행") == ("approve", None)
|
||||||
|
assert ci.classify("agree") == ("approve", None)
|
||||||
|
assert called["n"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_reject_only_no_llm(monkeypatch):
|
||||||
|
monkeypatch.setattr(ci, "_llm_classify", lambda t: ("unclear", None))
|
||||||
|
assert ci.classify("반려") == ("reject", None)
|
||||||
|
assert ci.classify("거절") == ("reject", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_with_text_split(monkeypatch):
|
||||||
|
monkeypatch.setattr(ci, "_llm_classify", lambda t: ("unclear", None))
|
||||||
|
intent, fb = ci.classify("반려, 제목 짧게")
|
||||||
|
assert intent == "reject"
|
||||||
|
assert "제목 짧게" in fb
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_ambiguous_calls_llm(monkeypatch):
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
|
||||||
|
respx.post("https://api.anthropic.com/v1/messages").mock(
|
||||||
|
return_value=Response(200, json={"content": [{"type": "text",
|
||||||
|
"text": '{"intent":"reject","feedback":"좀 더 화려하게"}'}]})
|
||||||
|
)
|
||||||
|
intent, fb = ci.classify("음... 좀 더 화려한 분위기가 좋겠어")
|
||||||
|
assert intent == "reject"
|
||||||
|
assert "화려하게" in fb
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_text_returns_unclear():
|
||||||
|
assert ci.classify("") == ("unclear", None)
|
||||||
|
assert ci.classify(None) == ("unclear", None)
|
||||||
55
agent-office/tests/test_curator_schema.py
Normal file
55
agent-office/tests/test_curator_schema.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.curator.schema import validate_response
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(nums, role="안정"):
|
||||||
|
return {"numbers": nums, "risk_tag": role, "reason": "x"}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_payload(core, bonus, ext, pool):
|
||||||
|
return {
|
||||||
|
"core_picks": core, "bonus_picks": bonus,
|
||||||
|
"extended_picks": ext, "pool_picks": pool,
|
||||||
|
"tier_rationale": {"bonus": "a", "extended": "b", "pool": "c"},
|
||||||
|
"narrative": {
|
||||||
|
"headline": "h",
|
||||||
|
"summary_3lines": ["1", "2", "3"],
|
||||||
|
"retrospective": "지난주 평균 1.8",
|
||||||
|
},
|
||||||
|
"confidence": 70,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_4tier():
|
||||||
|
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
|
||||||
|
cores = [_pick(pool[i]) for i in range(5)]
|
||||||
|
bonus = [_pick(pool[i]) for i in range(5, 10)]
|
||||||
|
ext = [_pick(pool[i]) for i in range(10, 15)]
|
||||||
|
pl = [_pick(pool[i]) for i in range(15, 20)]
|
||||||
|
out = validate_response(_make_payload(cores, bonus, ext, pl), pool)
|
||||||
|
assert len(out.core_picks) == 5
|
||||||
|
assert out.narrative.retrospective.startswith("지난주")
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_pick_rejected():
|
||||||
|
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
|
||||||
|
cores = [_pick(pool[0])] * 5 # 중복
|
||||||
|
bonus = [_pick(pool[i]) for i in range(5, 10)]
|
||||||
|
ext = [_pick(pool[i]) for i in range(10, 15)]
|
||||||
|
pl = [_pick(pool[i]) for i in range(15, 20)]
|
||||||
|
with pytest.raises(ValueError, match="duplicate"):
|
||||||
|
validate_response(_make_payload(cores, bonus, ext, pl), pool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_not_in_candidates_rejected():
|
||||||
|
pool = [[i, i+1, i+2, i+3, i+4, i+5] for i in range(1, 21)]
|
||||||
|
foreign = [40, 41, 42, 43, 44, 45]
|
||||||
|
cores = [_pick(foreign)] + [_pick(pool[i]) for i in range(1, 5)]
|
||||||
|
bonus = [_pick(pool[i]) for i in range(5, 10)]
|
||||||
|
ext = [_pick(pool[i]) for i in range(10, 15)]
|
||||||
|
pl = [_pick(pool[i]) for i in range(15, 20)]
|
||||||
|
with pytest.raises(ValueError, match="not in candidates"):
|
||||||
|
validate_response(_make_payload(cores, bonus, ext, pl), pool)
|
||||||
85
agent-office/tests/test_insta_agent.py
Normal file
85
agent-office/tests/test_insta_agent.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from unittest.mock import patch, AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.agents.insta import InstaAgent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _init_db():
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
os.remove(_TMP)
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_command_extract_dispatches(monkeypatch):
|
||||||
|
agent = InstaAgent()
|
||||||
|
fake_collect = AsyncMock(return_value={"task_id": "tcollect"})
|
||||||
|
fake_extract = AsyncMock(return_value={"task_id": "textract"})
|
||||||
|
fake_status = AsyncMock(side_effect=[
|
||||||
|
{"status": "succeeded", "result_id": 0},
|
||||||
|
{"status": "succeeded", "result_id": 0},
|
||||||
|
])
|
||||||
|
fake_keywords = AsyncMock(return_value=[
|
||||||
|
{"id": 1, "keyword": "K1", "category": "economy", "score": 0.9},
|
||||||
|
{"id": 2, "keyword": "K2", "category": "psychology", "score": 0.8},
|
||||||
|
])
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect", fake_collect)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_extract", fake_extract)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords", fake_keywords)
|
||||||
|
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||||
|
|
||||||
|
result = await agent.on_command("extract", {})
|
||||||
|
assert result["ok"] is True
|
||||||
|
fake_collect.assert_awaited()
|
||||||
|
fake_extract.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_callback_render_kicks_pipeline(monkeypatch):
|
||||||
|
agent = InstaAgent()
|
||||||
|
fake_kw = AsyncMock(return_value={"id": 7, "keyword": "테스트", "category": "economy"})
|
||||||
|
fake_create = AsyncMock(return_value={"task_id": "tslate"})
|
||||||
|
fake_status = AsyncMock(side_effect=[
|
||||||
|
{"status": "processing"},
|
||||||
|
{"status": "succeeded", "result_id": 42},
|
||||||
|
])
|
||||||
|
fake_slate = AsyncMock(return_value={
|
||||||
|
"id": 42, "status": "rendered",
|
||||||
|
"suggested_caption": "캡션", "hashtags": ["#a", "#b"],
|
||||||
|
"assets": [{"page_index": i, "file_path": f"/x/{i}.png"} for i in range(1, 11)],
|
||||||
|
})
|
||||||
|
fake_bytes = AsyncMock(side_effect=[b"PNG"] * 10)
|
||||||
|
fake_send_media = AsyncMock(return_value={"ok": True})
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_keyword", fake_kw)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_create_slate", fake_create)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_slate", fake_slate)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_asset_bytes", fake_bytes)
|
||||||
|
monkeypatch.setattr("app.agents.insta._send_media_group", fake_send_media)
|
||||||
|
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||||
|
|
||||||
|
out = await agent.on_callback("render", {"keyword_id": 7})
|
||||||
|
assert out["ok"] is True
|
||||||
|
fake_create.assert_awaited()
|
||||||
|
fake_send_media.assert_awaited()
|
||||||
73
agent-office/tests/test_insta_agent_trends.py
Normal file
73
agent-office/tests/test_insta_agent_trends.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.agents.insta import InstaAgent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _init_db():
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
os.remove(_TMP)
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_command_collect_trends_dispatches(monkeypatch):
|
||||||
|
agent = InstaAgent()
|
||||||
|
fake_collect = AsyncMock(return_value={"task_id": "tcollect"})
|
||||||
|
fake_status = AsyncMock(return_value={"status": "succeeded", "result_id": 8,
|
||||||
|
"message": "naver:5, google:3"})
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect_trends", fake_collect)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
|
||||||
|
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||||
|
|
||||||
|
result = await agent.on_command("collect_trends", {})
|
||||||
|
assert result["ok"] is True
|
||||||
|
fake_collect.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_schedule_loads_preferences(monkeypatch):
|
||||||
|
"""on_schedule이 preferences를 가져오는지 확인."""
|
||||||
|
agent = InstaAgent()
|
||||||
|
|
||||||
|
fake_collect = AsyncMock(return_value={"task_id": "t1"})
|
||||||
|
fake_extract = AsyncMock(return_value={"task_id": "t2"})
|
||||||
|
fake_status = AsyncMock(side_effect=[
|
||||||
|
{"status": "succeeded", "result_id": 0},
|
||||||
|
{"status": "succeeded", "result_id": 0},
|
||||||
|
])
|
||||||
|
fake_keywords = AsyncMock(return_value=[
|
||||||
|
{"id": 1, "keyword": "K", "category": "economy", "score": 0.9},
|
||||||
|
])
|
||||||
|
fake_prefs = AsyncMock(return_value={"economy": 0.6, "psychology": 0.4})
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_collect", fake_collect)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_extract", fake_extract)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_task_status", fake_status)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_list_keywords", fake_keywords)
|
||||||
|
monkeypatch.setattr("app.agents.insta.service_proxy.insta_get_preferences", fake_prefs)
|
||||||
|
monkeypatch.setattr("app.agents.insta.messaging.send_raw", AsyncMock(return_value={"ok": True}))
|
||||||
|
|
||||||
|
agent.state = "idle"
|
||||||
|
await agent.on_schedule()
|
||||||
|
|
||||||
|
fake_prefs.assert_awaited()
|
||||||
55
agent-office/tests/test_insta_keyword_filter.py
Normal file
55
agent-office/tests/test_insta_keyword_filter.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.agents.insta import _dedup_and_filter_keywords, KEYWORD_MIN_SCORE
|
||||||
|
|
||||||
|
|
||||||
|
def test_filters_below_threshold():
|
||||||
|
"""score < 임계값(0.7) 키워드는 제외."""
|
||||||
|
kws = [
|
||||||
|
{"id": 1, "keyword": "금리인하", "category": "경제", "score": 0.9},
|
||||||
|
{"id": 2, "keyword": "환율", "category": "경제", "score": 0.6}, # 컷
|
||||||
|
{"id": 3, "keyword": "반도체", "category": "경제", "score": 0.71},
|
||||||
|
]
|
||||||
|
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||||
|
kept = {k["keyword"] for k in out}
|
||||||
|
assert kept == {"금리인하", "반도체"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_keeps_highest_score():
|
||||||
|
"""동일 keyword 중복 시 최고 score 1개만 유지."""
|
||||||
|
kws = [
|
||||||
|
{"id": 1, "keyword": "AI", "category": "경제", "score": 0.75},
|
||||||
|
{"id": 2, "keyword": "AI", "category": "기술", "score": 0.92}, # 같은 키워드, 더 높음
|
||||||
|
]
|
||||||
|
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["id"] == 2
|
||||||
|
assert out[0]["score"] == 0.92
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorted_by_score_desc():
|
||||||
|
kws = [
|
||||||
|
{"id": 1, "keyword": "a", "category": "c", "score": 0.72},
|
||||||
|
{"id": 2, "keyword": "b", "category": "c", "score": 0.95},
|
||||||
|
{"id": 3, "keyword": "c", "category": "c", "score": 0.80},
|
||||||
|
]
|
||||||
|
out = _dedup_and_filter_keywords(kws, min_score=0.7)
|
||||||
|
assert [k["keyword"] for k in out] == ["b", "c", "a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_when_all_below_threshold():
|
||||||
|
kws = [{"id": 1, "keyword": "x", "category": "c", "score": 0.4}]
|
||||||
|
assert _dedup_and_filter_keywords(kws, min_score=0.7) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_threshold_is_0_7():
|
||||||
|
assert KEYWORD_MIN_SCORE == 0.7
|
||||||
87
agent-office/tests/test_lotto_evolution_format.py
Normal file
87
agent-office/tests/test_lotto_evolution_format.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from app.notifiers.telegram_lotto import _format_evolution_report
|
||||||
|
|
||||||
|
|
||||||
|
def test_evolution_report_winner_4plus():
|
||||||
|
eval_result = {
|
||||||
|
"ok": True,
|
||||||
|
"draw_no": 1225,
|
||||||
|
"week_start": "2026-05-18",
|
||||||
|
"winner": {
|
||||||
|
"day_of_week": 3,
|
||||||
|
"weight": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||||
|
"avg_score": 0.42,
|
||||||
|
"max_correct": 4,
|
||||||
|
"n_picks": 5,
|
||||||
|
},
|
||||||
|
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||||
|
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||||
|
"update_reason": "winner_4plus",
|
||||||
|
"per_day": [
|
||||||
|
{"day_of_week": 0, "avg_score": 0.20, "max_correct": 2},
|
||||||
|
{"day_of_week": 3, "avg_score": 0.42, "max_correct": 4},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
|
||||||
|
text = _format_evolution_report(eval_result, current_base)
|
||||||
|
assert "🧬" in text
|
||||||
|
assert "1225" in text
|
||||||
|
assert "목요일" in text or "Winner" in text
|
||||||
|
assert "4개 일치" in text or "max=4" in text
|
||||||
|
assert "winner_4plus" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_evolution_report_unchanged():
|
||||||
|
eval_result = {
|
||||||
|
"ok": True,
|
||||||
|
"draw_no": 1226,
|
||||||
|
"week_start": "2026-05-25",
|
||||||
|
"winner": {
|
||||||
|
"day_of_week": 1,
|
||||||
|
"weight": [0.21, 0.19, 0.20, 0.20, 0.20],
|
||||||
|
"avg_score": 0.10,
|
||||||
|
"max_correct": 2,
|
||||||
|
"n_picks": 5,
|
||||||
|
},
|
||||||
|
"new_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||||
|
"update_reason": "unchanged",
|
||||||
|
"per_day": [],
|
||||||
|
}
|
||||||
|
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
|
||||||
|
text = _format_evolution_report(eval_result, current_base)
|
||||||
|
assert "unchanged" in text or "유지" in text
|
||||||
|
assert "2개 일치" in text or "max=2" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_evolution_report_empty_returns_empty():
|
||||||
|
"""evaluate가 ok=False면 빈 문자열 (발송 skip)."""
|
||||||
|
text = _format_evolution_report({"ok": False, "reason": "no_trials"}, [0.2]*5)
|
||||||
|
assert text == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_evolution_report_uses_previous_base_for_diff():
|
||||||
|
"""previous_base와 new_base 차이가 메시지 diff에 정확히 반영됨."""
|
||||||
|
eval_result = {
|
||||||
|
"ok": True,
|
||||||
|
"draw_no": 1227,
|
||||||
|
"winner": {
|
||||||
|
"day_of_week": 0,
|
||||||
|
"weight": [0.30, 0.20, 0.20, 0.20, 0.10],
|
||||||
|
"avg_score": 0.50,
|
||||||
|
"max_correct": 4,
|
||||||
|
"n_picks": 5,
|
||||||
|
},
|
||||||
|
"new_base": [0.30, 0.20, 0.20, 0.20, 0.10],
|
||||||
|
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
|
||||||
|
"update_reason": "winner_4plus",
|
||||||
|
}
|
||||||
|
# current_base는 stale (post-update 값) — previous_base가 우선 적용되어야 함
|
||||||
|
text = _format_evolution_report(eval_result, [0.30, 0.20, 0.20, 0.20, 0.10])
|
||||||
|
# freq: 0.20 → 0.30 (+0.10 = "++")
|
||||||
|
# divers: 0.20 → 0.10 (-0.10 = "--")
|
||||||
|
assert "0.20 → 0.30" in text # freq 증가
|
||||||
|
assert "0.20 → 0.10" in text # divers 감소
|
||||||
|
assert "(++)" in text or "(+)" in text # freq marker
|
||||||
|
assert "(--)" in text or "(-)" in text # divers marker
|
||||||
116
agent-office/tests/test_lotto_signal_runner.py
Normal file
116
agent-office/tests/test_lotto_signal_runner.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import gc
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.curator import signal_runner
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
db.DB_PATH = _TMP # patch frozen module-level DB_PATH (import order safety)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db():
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
os.remove(_TMP)
|
||||||
|
db.init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
try:
|
||||||
|
os.remove(_TMP)
|
||||||
|
except PermissionError:
|
||||||
|
pass # Windows: WAL-mode file locked; DB is ephemeral anyway
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_and_persist_cold_start():
|
||||||
|
"""첫 호출은 warmup으로 기록되고 baseline에 값이 들어간다."""
|
||||||
|
result = signal_runner.evaluate_metric_and_persist(
|
||||||
|
source="light",
|
||||||
|
metric="sim_signal",
|
||||||
|
value=1.5,
|
||||||
|
draw_no=None,
|
||||||
|
z_normal=1.5,
|
||||||
|
z_urgent=2.5,
|
||||||
|
push_to_window=True,
|
||||||
|
)
|
||||||
|
assert result["fire_level"] == "warmup"
|
||||||
|
assert result["z_score"] is None
|
||||||
|
|
||||||
|
bl = db.get_baseline("sim_signal")
|
||||||
|
assert bl is not None
|
||||||
|
assert bl["window_values"] == [1.5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_after_window_filled_normal_fire():
|
||||||
|
"""8회 push 후 정상 운영, 평균 대비 z≥1.5면 normal."""
|
||||||
|
for v in [1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0]:
|
||||||
|
signal_runner.evaluate_metric_and_persist(
|
||||||
|
source="sim",
|
||||||
|
metric="sim_signal",
|
||||||
|
value=v,
|
||||||
|
draw_no=None,
|
||||||
|
z_normal=1.5,
|
||||||
|
z_urgent=2.5,
|
||||||
|
push_to_window=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = signal_runner.evaluate_metric_and_persist(
|
||||||
|
source="sim",
|
||||||
|
metric="sim_signal",
|
||||||
|
value=1.12,
|
||||||
|
draw_no=None,
|
||||||
|
z_normal=1.5,
|
||||||
|
z_urgent=2.5,
|
||||||
|
push_to_window=True,
|
||||||
|
)
|
||||||
|
assert result["fire_level"] in ("normal", "urgent")
|
||||||
|
assert result["z_score"] is not None and result["z_score"] >= 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_drift_skips_same_draw_push():
|
||||||
|
"""drift는 회차 단위. 같은 회차에서 두 번 호출하면 두 번째는 window push X."""
|
||||||
|
signal_runner.evaluate_metric_and_persist(
|
||||||
|
source="sim", metric="drift", value=0.05, draw_no=1100,
|
||||||
|
z_normal=1.5, z_urgent=2.5, push_to_window=True,
|
||||||
|
)
|
||||||
|
bl_before = db.get_baseline("drift")
|
||||||
|
assert bl_before["window_values"] == [0.05]
|
||||||
|
assert bl_before["last_pushed_draw_no"] == 1100
|
||||||
|
|
||||||
|
signal_runner.evaluate_metric_and_persist(
|
||||||
|
source="sim", metric="drift", value=0.08, draw_no=1100,
|
||||||
|
z_normal=1.5, z_urgent=2.5, push_to_window=True,
|
||||||
|
)
|
||||||
|
bl_after = db.get_baseline("drift")
|
||||||
|
assert bl_after["window_values"] == [0.05]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_signal_check_aggregates_three_metrics(monkeypatch):
|
||||||
|
"""run_signal_check이 3종 메트릭 모두 평가하고 overall fire를 반환."""
|
||||||
|
async def fake_lotto_best():
|
||||||
|
return [{"numbers": [1,2,3,4,5,6], "scores": [10,10,10,10,10]}] * 20
|
||||||
|
|
||||||
|
async def fake_lotto_strategy_weights():
|
||||||
|
return {"gap_focus": 0.4, "hot_focus": 0.3, "pair_bias": 0.3}
|
||||||
|
|
||||||
|
monkeypatch.setattr(signal_runner, "_fetch_best_picks", fake_lotto_best)
|
||||||
|
monkeypatch.setattr(signal_runner, "_fetch_strategy_weights", fake_lotto_strategy_weights)
|
||||||
|
|
||||||
|
out = await signal_runner.run_signal_check(source="light", curate_result=None, current_draw_no=1101)
|
||||||
|
assert "overall_fire" in out
|
||||||
|
assert "results" in out
|
||||||
|
assert any(r["metric"] == "sim_signal" for r in out["results"])
|
||||||
|
# light_check는 confidence 평가 안 함
|
||||||
|
assert not any(r["metric"] == "confidence" for r in out["results"])
|
||||||
130
agent-office/tests/test_lotto_signals.py
Normal file
130
agent-office/tests/test_lotto_signals.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# agent-office/tests/test_lotto_signals.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.curator import signals
|
||||||
|
|
||||||
|
|
||||||
|
def test_sim_consensus_top10_geomean():
|
||||||
|
"""top-10 consensus 평균이 기하평균 기반인지."""
|
||||||
|
best_picks = [
|
||||||
|
{"scores": [10, 10, 10, 10, 10]}, # high & uniform
|
||||||
|
{"scores": [9, 9, 9, 9, 9]},
|
||||||
|
{"scores": [8, 8, 8, 8, 8]},
|
||||||
|
{"scores": [7, 7, 7, 7, 7]},
|
||||||
|
{"scores": [6, 6, 6, 6, 6]},
|
||||||
|
{"scores": [5, 5, 5, 5, 5]},
|
||||||
|
{"scores": [4, 4, 4, 4, 4]},
|
||||||
|
{"scores": [3, 3, 3, 3, 3]},
|
||||||
|
{"scores": [2, 2, 2, 2, 2]},
|
||||||
|
{"scores": [1, 1, 1, 1, 1]}, # top 10
|
||||||
|
{"scores": [0, 0, 0, 0, 0]}, # bottom 10
|
||||||
|
] * 1 + [{"scores": [0, 0, 0, 0, 0]}] * 10
|
||||||
|
result = signals.sim_consensus_score(best_picks)
|
||||||
|
assert 0.0 <= result <= 1.0
|
||||||
|
assert result > 0.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_sim_consensus_geomean_penalizes_imbalance():
|
||||||
|
"""5종 중 한 종만 폭주하는 outlier 후보는 균형 후보보다 작아야 한다."""
|
||||||
|
balanced = [{"scores": [5, 5, 5, 5, 5]}] * 20
|
||||||
|
imbalanced = [{"scores": [25, 0, 0, 0, 0]}] * 20
|
||||||
|
s_balanced = signals.sim_consensus_score(balanced)
|
||||||
|
s_imbalanced = signals.sim_consensus_score(imbalanced)
|
||||||
|
assert s_imbalanced < s_balanced
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_drift_score():
|
||||||
|
"""drift = 전략별 가중치 변화 절댓값 합."""
|
||||||
|
w_prev = {"gap_focus": 0.30, "hot_focus": 0.25, "pair_bias": 0.45}
|
||||||
|
w_curr = {"gap_focus": 0.40, "hot_focus": 0.20, "pair_bias": 0.40}
|
||||||
|
result = signals.strategy_drift_score(w_prev, w_curr)
|
||||||
|
assert abs(result - 0.20) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_drift_new_strategy_appears():
|
||||||
|
"""이전에 없던 전략이 등장하면 그 가중치 전체가 drift에 가산."""
|
||||||
|
w_prev = {"gap_focus": 0.5, "hot_focus": 0.5}
|
||||||
|
w_curr = {"gap_focus": 0.4, "hot_focus": 0.4, "newbie": 0.2}
|
||||||
|
result = signals.strategy_drift_score(w_prev, w_curr)
|
||||||
|
assert abs(result - 0.4) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_score_passthrough():
|
||||||
|
"""confidence는 큐레이션 결과의 값 그대로 (0~1 clamp 확인)."""
|
||||||
|
assert signals.confidence_score({"confidence": 0.85}) == 0.85
|
||||||
|
assert signals.confidence_score({"confidence": 1.2}) == 1.0
|
||||||
|
assert signals.confidence_score({"confidence": -0.1}) == 0.0
|
||||||
|
assert signals.confidence_score({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_baseline_cold_start():
|
||||||
|
"""window 크기 < 4 → warmup, z=None."""
|
||||||
|
bl = signals.AdaptiveBaseline(window=[1.0, 1.1, 0.9], window_max=8)
|
||||||
|
z, fire = bl.evaluate(value=1.5, z_normal=1.5, z_urgent=2.5)
|
||||||
|
assert fire == "warmup"
|
||||||
|
assert z is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_baseline_preparing():
|
||||||
|
"""window 4~7 → 보수적 임계치 z=2.0."""
|
||||||
|
bl = signals.AdaptiveBaseline(window=[1.0, 1.0, 1.0, 1.0], window_max=8)
|
||||||
|
z, fire = bl.evaluate(value=3.0, z_normal=1.5, z_urgent=2.5)
|
||||||
|
assert fire in ("normal", "urgent")
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_baseline_normal_window_full():
|
||||||
|
"""window 8 풀, value가 평균보다 1.5σ 이상이면 normal."""
|
||||||
|
bl = signals.AdaptiveBaseline(
|
||||||
|
window=[1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0],
|
||||||
|
window_max=8,
|
||||||
|
)
|
||||||
|
z, fire = bl.evaluate(value=1.12, z_normal=1.5, z_urgent=2.5)
|
||||||
|
assert fire == "normal"
|
||||||
|
assert z is not None and z >= 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_baseline_urgent():
|
||||||
|
"""z >= 2.5 → urgent."""
|
||||||
|
bl = signals.AdaptiveBaseline(
|
||||||
|
window=[1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0],
|
||||||
|
window_max=8,
|
||||||
|
)
|
||||||
|
z, fire = bl.evaluate(value=2.0, z_normal=1.5, z_urgent=2.5)
|
||||||
|
assert fire == "urgent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adaptive_baseline_push_updates_window():
|
||||||
|
"""push 시 FIFO 동작."""
|
||||||
|
bl = signals.AdaptiveBaseline(window=[1, 2, 3, 4, 5, 6, 7, 8], window_max=8)
|
||||||
|
bl.push(9.0)
|
||||||
|
assert bl.window == [2, 3, 4, 5, 6, 7, 8, 9.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_decide_fire_level_two_normals_escalate():
|
||||||
|
sigs = [
|
||||||
|
{"metric": "sim", "z": 1.6, "fire": "normal"},
|
||||||
|
{"metric": "drift", "z": 1.7, "fire": "normal"},
|
||||||
|
{"metric": "conf", "z": 0.5, "fire": "noop"},
|
||||||
|
]
|
||||||
|
assert signals.decide_overall_fire(sigs) == "urgent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decide_fire_level_single_normal():
|
||||||
|
sigs = [
|
||||||
|
{"metric": "sim", "z": 1.6, "fire": "normal"},
|
||||||
|
{"metric": "drift", "z": 0.3, "fire": "noop"},
|
||||||
|
]
|
||||||
|
assert signals.decide_overall_fire(sigs) == "normal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decide_fire_level_single_urgent():
|
||||||
|
sigs = [
|
||||||
|
{"metric": "sim", "z": 3.0, "fire": "urgent"},
|
||||||
|
{"metric": "drift", "z": 0.2, "fire": "noop"},
|
||||||
|
]
|
||||||
|
assert signals.decide_overall_fire(sigs) == "urgent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decide_fire_level_all_noop():
|
||||||
|
sigs = [{"metric": "sim", "z": 0.5, "fire": "noop"}]
|
||||||
|
assert signals.decide_overall_fire(sigs) == "noop"
|
||||||
154
agent-office/tests/test_lotto_task_wrap.py
Normal file
154
agent-office/tests/test_lotto_task_wrap.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# agent-office/tests/test_lotto_task_wrap.py
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import gc
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app import db
|
||||||
|
db.DB_PATH = _TMP
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db():
|
||||||
|
# Re-patch DB_PATH at the start of every test (cross-file isolation)
|
||||||
|
db.DB_PATH = _TMP
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
os.remove(_TMP)
|
||||||
|
db.init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
try:
|
||||||
|
os.remove(_TMP)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_signal_check_creates_task_row(monkeypatch):
|
||||||
|
"""run_signal_check이 agent_tasks에 row를 만들고 result_data를 저장."""
|
||||||
|
from app.agents.lotto import LottoAgent
|
||||||
|
from app.curator import signal_runner
|
||||||
|
|
||||||
|
async def fake_run_signal_check(**kwargs):
|
||||||
|
return {
|
||||||
|
"overall_fire": "normal",
|
||||||
|
"results": [
|
||||||
|
{"signal_id": 1, "metric": "sim_signal",
|
||||||
|
"value": 0.6, "z_score": 1.7, "fire_level": "normal",
|
||||||
|
"baseline_mu": 0.5, "baseline_sigma": 0.05, "payload": {}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(signal_runner, "run_signal_check", fake_run_signal_check)
|
||||||
|
|
||||||
|
from app import service_proxy
|
||||||
|
async def fake_latest():
|
||||||
|
return 1226
|
||||||
|
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
|
||||||
|
|
||||||
|
from app.notifiers import telegram_lotto
|
||||||
|
async def fake_send(_event): pass
|
||||||
|
monkeypatch.setattr(telegram_lotto, "send_urgent_signal", fake_send)
|
||||||
|
|
||||||
|
agent = LottoAgent()
|
||||||
|
result = await agent.run_signal_check(source="light")
|
||||||
|
assert result["ok"] is True
|
||||||
|
|
||||||
|
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
|
||||||
|
assert len(tasks) == 1
|
||||||
|
t = tasks[0]
|
||||||
|
assert t["status"] == "succeeded"
|
||||||
|
assert t["result_data"]["source"] == "light"
|
||||||
|
assert t["result_data"]["overall_fire"] == "normal"
|
||||||
|
assert "sim_signal" in t["result_data"]["fired_metrics"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_signal_check_failure_marks_task_failed(monkeypatch):
|
||||||
|
from app.agents.lotto import LottoAgent
|
||||||
|
from app.curator import signal_runner
|
||||||
|
from app import service_proxy
|
||||||
|
|
||||||
|
async def boom(**kwargs):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
monkeypatch.setattr(signal_runner, "run_signal_check", boom)
|
||||||
|
|
||||||
|
async def fake_latest():
|
||||||
|
return 1226
|
||||||
|
monkeypatch.setattr(service_proxy, "lotto_latest_draw", fake_latest)
|
||||||
|
|
||||||
|
agent = LottoAgent()
|
||||||
|
result = await agent.run_signal_check(source="sim")
|
||||||
|
assert result["ok"] is False
|
||||||
|
|
||||||
|
tasks = db.get_agent_tasks("lotto", task_type="signal_check", days=1)
|
||||||
|
assert len(tasks) == 1
|
||||||
|
assert tasks[0]["status"] == "failed"
|
||||||
|
assert "boom" in tasks[0]["result_data"]["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_daily_digest_creates_task(monkeypatch):
|
||||||
|
"""run_daily_digest이 agent_tasks에 task 생성 + result_data 저장."""
|
||||||
|
from app.agents.lotto import LottoAgent
|
||||||
|
from app.notifiers import telegram_lotto
|
||||||
|
|
||||||
|
async def fake_send(_d): pass
|
||||||
|
monkeypatch.setattr(telegram_lotto, "send_signal_summary", fake_send)
|
||||||
|
|
||||||
|
agent = LottoAgent()
|
||||||
|
result = await agent.run_daily_digest()
|
||||||
|
assert result["ok"] is True
|
||||||
|
|
||||||
|
tasks = db.get_agent_tasks("lotto", task_type="daily_digest", days=1)
|
||||||
|
assert len(tasks) == 1
|
||||||
|
assert tasks[0]["status"] == "succeeded"
|
||||||
|
assert "fired" in tasks[0]["result_data"]
|
||||||
|
assert "evaluated" in tasks[0]["result_data"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_weekly_evolution_report_creates_task(monkeypatch):
|
||||||
|
"""run_weekly_evolution_report이 task 생성 + result_data 저장."""
|
||||||
|
from app.agents.lotto import LottoAgent
|
||||||
|
from app import service_proxy
|
||||||
|
from app.notifiers import telegram_lotto
|
||||||
|
|
||||||
|
async def fake_eval():
|
||||||
|
return {
|
||||||
|
"ok": True, "draw_no": 1225,
|
||||||
|
"winner": {"day_of_week": 3, "weight": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||||
|
"avg_score": 0.42, "max_correct": 4, "n_picks": 5},
|
||||||
|
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
|
||||||
|
"previous_base": [0.2] * 5,
|
||||||
|
"update_reason": "winner_4plus",
|
||||||
|
}
|
||||||
|
async def fake_status():
|
||||||
|
return {"current_base": [0.2] * 5}
|
||||||
|
async def fake_send(_e, _b): pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(service_proxy, "lotto_evolver_evaluate", fake_eval)
|
||||||
|
monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status)
|
||||||
|
monkeypatch.setattr(telegram_lotto, "send_evolution_report", fake_send)
|
||||||
|
|
||||||
|
agent = LottoAgent()
|
||||||
|
result = await agent.run_weekly_evolution_report()
|
||||||
|
assert result["ok"] is True
|
||||||
|
|
||||||
|
tasks = db.get_agent_tasks("lotto", task_type="weekly_evolution_report", days=1)
|
||||||
|
assert len(tasks) == 1
|
||||||
|
r = tasks[0]["result_data"]
|
||||||
|
assert tasks[0]["status"] == "succeeded"
|
||||||
|
assert r["draw_no"] == 1225
|
||||||
|
assert r["update_reason"] == "winner_4plus"
|
||||||
|
assert r["winner_day_of_week"] == 3
|
||||||
|
assert r["winner_max_correct"] == 4
|
||||||
49
agent-office/tests/test_lotto_telegram_signal.py
Normal file
49
agent-office/tests/test_lotto_telegram_signal.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from app.notifiers.telegram_lotto import (
|
||||||
|
_format_urgent_signal,
|
||||||
|
_format_signal_digest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_urgent_signal_format_basic():
|
||||||
|
event = {
|
||||||
|
"fire_level": "urgent",
|
||||||
|
"triggered_at": "2026-05-20T07:18:00.000Z",
|
||||||
|
"results": [
|
||||||
|
{"metric": "sim_signal", "value": 1.84, "z_score": 3.9,
|
||||||
|
"baseline_mu": 1.02, "baseline_sigma": 0.21, "payload": {},
|
||||||
|
"fire_level": "urgent"},
|
||||||
|
{"metric": "drift", "value": 0.18, "z_score": 3.0,
|
||||||
|
"baseline_mu": 0.06, "baseline_sigma": 0.04, "fire_level": "normal",
|
||||||
|
"payload": {"weights_now": {"gap_focus": 0.5, "hot_focus": 0.5},
|
||||||
|
"weights_prev": {"gap_focus": 0.3, "hot_focus": 0.7}}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
text = _format_urgent_signal(event)
|
||||||
|
assert "🚨" in text
|
||||||
|
assert "Sim Consensus" in text
|
||||||
|
assert "z=3.9" in text
|
||||||
|
assert "Strategy Drift" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_digest_format_with_signals():
|
||||||
|
digest = {
|
||||||
|
"evaluated": 6,
|
||||||
|
"fired": 2,
|
||||||
|
"signals": [
|
||||||
|
{"metric": "sim_signal", "fire_level": "normal", "z_score": 1.7,
|
||||||
|
"triggered_at": "2026-05-20T16:18:00Z", "payload": {}},
|
||||||
|
{"metric": "confidence", "fire_level": "normal", "z_score": 1.6,
|
||||||
|
"triggered_at": "2026-05-20T09:05:00Z", "payload": {}},
|
||||||
|
],
|
||||||
|
"weights_trend": {"gap_focus": +0.12, "hot_focus": -0.02, "pair_bias": -0.08},
|
||||||
|
}
|
||||||
|
text = _format_signal_digest(digest)
|
||||||
|
assert "📊" in text
|
||||||
|
assert "지난 24h" in text
|
||||||
|
assert "z=1.7" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_digest_empty_returns_empty_string():
|
||||||
|
"""발화 0건이면 빈 문자열 → 발송 자체 skip 가능."""
|
||||||
|
text = _format_signal_digest({"evaluated": 6, "fired": 0, "signals": [], "weights_trend": {}})
|
||||||
|
assert text == ""
|
||||||
132
agent-office/tests/test_pipeline_polling.py
Normal file
132
agent-office/tests/test_pipeline_polling.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _init_db():
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
os.remove(_TMP)
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_notifies_once_per_state():
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
pipelines = [{
|
||||||
|
"id": 1,
|
||||||
|
"state": "cover_pending",
|
||||||
|
"cover_url": "/x.jpg",
|
||||||
|
"track_title": "Test",
|
||||||
|
"feedback_count_per_step": {},
|
||||||
|
}]
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||||
|
new=AsyncMock(return_value=pipelines),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=AsyncMock(return_value={"ok": True, "message_id": 99}),
|
||||||
|
) as mock_send, patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
|
||||||
|
new=AsyncMock(),
|
||||||
|
):
|
||||||
|
a = YoutubePublisherAgent()
|
||||||
|
await a.poll_state_changes()
|
||||||
|
await a.poll_state_changes() # 같은 상태 — 두 번째는 알림 안 함
|
||||||
|
assert mock_send.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_renotifies_on_reject_regen(monkeypatch):
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
pipelines_v1 = [{"id": 1, "state": "cover_pending", "cover_url": "/x.jpg",
|
||||||
|
"track_title": "Test", "feedback_count_per_step": {}}]
|
||||||
|
pipelines_v2 = [{"id": 1, "state": "cover_pending", "cover_url": "/x2.jpg",
|
||||||
|
"track_title": "Test", "feedback_count_per_step": {"cover": 1}}]
|
||||||
|
list_mock = AsyncMock(side_effect=[pipelines_v1, pipelines_v2])
|
||||||
|
with patch("app.agents.youtube_publisher.service_proxy.list_active_pipelines", list_mock), \
|
||||||
|
patch("app.agents.youtube_publisher.send_raw",
|
||||||
|
new=AsyncMock(return_value={"ok": True, "message_id": 99})), \
|
||||||
|
patch("app.agents.youtube_publisher.service_proxy.save_pipeline_telegram_msg",
|
||||||
|
new=AsyncMock()):
|
||||||
|
a = YoutubePublisherAgent()
|
||||||
|
await a.poll_state_changes() # 1st: notify
|
||||||
|
await a.poll_state_changes() # 2nd: feedback count differs → notify again
|
||||||
|
from app.agents.youtube_publisher import send_raw as sr
|
||||||
|
assert sr.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_telegram_reply_approve_calls_feedback():
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.post_pipeline_feedback",
|
||||||
|
new=AsyncMock(),
|
||||||
|
) as mock_fb, patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=AsyncMock(),
|
||||||
|
):
|
||||||
|
a = YoutubePublisherAgent()
|
||||||
|
await a.on_telegram_reply(pipeline_id=42, step="cover", user_text="승인")
|
||||||
|
mock_fb.assert_called_once_with(42, "cover", "approve", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_telegram_reply_reject_with_feedback():
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.post_pipeline_feedback",
|
||||||
|
new=AsyncMock(),
|
||||||
|
) as mock_fb, patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=AsyncMock(),
|
||||||
|
):
|
||||||
|
a = YoutubePublisherAgent()
|
||||||
|
await a.on_telegram_reply(pipeline_id=43, step="meta", user_text="반려, 제목 짧게")
|
||||||
|
args = mock_fb.call_args[0]
|
||||||
|
assert args[0] == 43
|
||||||
|
assert args[1] == "meta"
|
||||||
|
assert args[2] == "reject"
|
||||||
|
assert "제목 짧게" in (args[3] or "")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_telegram_reply_unclear_asks_again():
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def mock_send(text=None, **kw):
|
||||||
|
sent.append(text)
|
||||||
|
return {"ok": True, "message_id": 1}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=mock_send,
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.classify_intent.classify",
|
||||||
|
return_value=("unclear", None),
|
||||||
|
):
|
||||||
|
a = YoutubePublisherAgent()
|
||||||
|
await a.on_telegram_reply(pipeline_id=44, step="cover", user_text="huh?")
|
||||||
|
assert any("다시 입력" in (s or "") for s in sent)
|
||||||
99
agent-office/tests/test_realestate_agent.py
Normal file
99
agent-office/tests/test_realestate_agent.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _init_db():
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
os.remove(_TMP)
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_new_matches_returns_empty_when_no_matches():
|
||||||
|
from app.agents.realestate import RealestateAgent
|
||||||
|
|
||||||
|
agent = RealestateAgent()
|
||||||
|
result = asyncio.run(agent.on_new_matches([]))
|
||||||
|
assert result == {"sent": 0, "sent_ids": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_new_matches_sends_telegram_and_returns_ids():
|
||||||
|
from app.agents.realestate import RealestateAgent
|
||||||
|
from app.telegram import messaging
|
||||||
|
|
||||||
|
matches = [{
|
||||||
|
"id": 7, "match_score": 80, "house_nm": "단지A",
|
||||||
|
"region_name": "서울특별시", "district": "강남구",
|
||||||
|
"receipt_start": "2026-05-01", "receipt_end": "2026-05-05",
|
||||||
|
"match_reasons": [], "eligible_types": [], "pblanc_url": "https://x.test/7",
|
||||||
|
}]
|
||||||
|
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 123})
|
||||||
|
with patch.object(messaging, "send_raw", fake_send):
|
||||||
|
agent = RealestateAgent()
|
||||||
|
result = asyncio.run(agent.on_new_matches(matches))
|
||||||
|
|
||||||
|
assert result["sent"] == 1
|
||||||
|
assert result["sent_ids"] == [7]
|
||||||
|
assert result["message_id"] == 123
|
||||||
|
fake_send.assert_awaited_once()
|
||||||
|
args, kwargs = fake_send.call_args
|
||||||
|
text = args[0]
|
||||||
|
assert "단지A" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_new_matches_telegram_failure_returns_zero():
|
||||||
|
from app.agents.realestate import RealestateAgent
|
||||||
|
from app.telegram import messaging
|
||||||
|
|
||||||
|
matches = [{
|
||||||
|
"id": 8, "match_score": 80, "house_nm": "단지B",
|
||||||
|
"region_name": "서울", "district": "송파구",
|
||||||
|
"receipt_start": "", "receipt_end": "",
|
||||||
|
"match_reasons": [], "eligible_types": [], "pblanc_url": "",
|
||||||
|
}]
|
||||||
|
|
||||||
|
fake_send = AsyncMock(return_value={"ok": False, "description": "401"})
|
||||||
|
with patch.object(messaging, "send_raw", fake_send):
|
||||||
|
agent = RealestateAgent()
|
||||||
|
result = asyncio.run(agent.on_new_matches(matches))
|
||||||
|
|
||||||
|
assert result["sent"] == 0
|
||||||
|
assert result["sent_ids"] == []
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_calls_agent_on_new_matches():
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.main import app
|
||||||
|
from app.agents.realestate import RealestateAgent
|
||||||
|
|
||||||
|
fake = AsyncMock(return_value={"sent": 1, "sent_ids": [99], "message_id": 1})
|
||||||
|
with patch.object(RealestateAgent, "on_new_matches", fake):
|
||||||
|
with TestClient(app) as client:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/agent-office/realestate/notify",
|
||||||
|
json={"matches": [{"id": 99, "match_score": 80}]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["sent"] == 1
|
||||||
|
assert body["sent_ids"] == [99]
|
||||||
133
agent-office/tests/test_realestate_callback.py
Normal file
133
agent-office/tests/test_realestate_callback.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import gc
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _init_db():
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
try:
|
||||||
|
os.remove(_TMP)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_realestate_bookmark_calls_proxy():
|
||||||
|
"""callback_data 'realestate_bookmark_42' 가 service_proxy.realestate_bookmark_toggle(42) 를 호출하고
|
||||||
|
is_bookmarked=1 이면 '추가 완료' 메시지를 전송한다."""
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import webhook
|
||||||
|
|
||||||
|
fake_toggle = AsyncMock(return_value={"is_bookmarked": 1})
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True})
|
||||||
|
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cb1",
|
||||||
|
"from": {"id": 1},
|
||||||
|
"data": "realestate_bookmark_42",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \
|
||||||
|
patch("app.telegram.messaging.send_raw", fake_send), \
|
||||||
|
patch("app.telegram.webhook.api_call", fake_api_call):
|
||||||
|
result = asyncio.run(webhook.handle_webhook(update))
|
||||||
|
|
||||||
|
fake_toggle.assert_awaited_once_with(42)
|
||||||
|
assert result == {"ok": True, "announcement_id": 42}
|
||||||
|
args, _ = fake_send.call_args
|
||||||
|
assert "추가" in args[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_realestate_bookmark_invalid_id():
|
||||||
|
"""callback_data 'realestate_bookmark_abc' 는 ValueError를 처리하고 에러 응답 반환."""
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import webhook
|
||||||
|
|
||||||
|
fake_toggle = AsyncMock(return_value={"bookmarked": True})
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True})
|
||||||
|
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cb2",
|
||||||
|
"from": {"id": 1},
|
||||||
|
"data": "realestate_bookmark_abc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \
|
||||||
|
patch("app.telegram.messaging.send_raw", fake_send), \
|
||||||
|
patch("app.telegram.webhook.api_call", fake_api_call):
|
||||||
|
result = asyncio.run(webhook.handle_webhook(update))
|
||||||
|
|
||||||
|
fake_toggle.assert_not_awaited()
|
||||||
|
assert result is not None
|
||||||
|
assert result.get("ok") is False
|
||||||
|
assert result.get("error") == "invalid_callback_data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_realestate_bookmark_proxy_error():
|
||||||
|
"""service_proxy 가 예외를 던질 때 에러 응답 반환."""
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import webhook
|
||||||
|
|
||||||
|
fake_toggle = AsyncMock(side_effect=Exception("connection refused"))
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True})
|
||||||
|
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cb3",
|
||||||
|
"from": {"id": 1},
|
||||||
|
"data": "realestate_bookmark_99",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "realestate_bookmark_toggle", fake_toggle), \
|
||||||
|
patch("app.telegram.messaging.send_raw", fake_send), \
|
||||||
|
patch("app.telegram.webhook.api_call", fake_api_call):
|
||||||
|
result = asyncio.run(webhook.handle_webhook(update))
|
||||||
|
|
||||||
|
fake_toggle.assert_awaited_once_with(99)
|
||||||
|
assert result is not None
|
||||||
|
assert result.get("ok") is False
|
||||||
|
assert "connection refused" in result.get("error", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_realestate_callback_uses_db_path():
|
||||||
|
"""approve_*/reject_* 콜백은 기존 DB 조회 경로를 사용 (realestate 분기를 타지 않음)."""
|
||||||
|
from app.telegram import webhook
|
||||||
|
|
||||||
|
fake_api_call = AsyncMock(return_value={"ok": True})
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cb4",
|
||||||
|
"from": {"id": 1},
|
||||||
|
"data": "approve_abcd1234",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# DB에 등록되지 않은 콜백이므로 None 반환 — 기존 로직 진입 확인
|
||||||
|
with patch("app.telegram.webhook.api_call", fake_api_call):
|
||||||
|
result = asyncio.run(webhook.handle_webhook(update))
|
||||||
|
|
||||||
|
assert result is None # DB에 없으면 None 반환 (기존 동작 유지)
|
||||||
59
agent-office/tests/test_realestate_message.py
Normal file
59
agent-office/tests/test_realestate_message.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
def test_format_realestate_match_full_card_single():
|
||||||
|
from app.telegram.realestate_message import format_realestate_matches
|
||||||
|
matches = [{
|
||||||
|
"id": 1,
|
||||||
|
"match_score": 90,
|
||||||
|
"house_nm": "디에이치 강남",
|
||||||
|
"region_name": "서울특별시",
|
||||||
|
"district": "강남구",
|
||||||
|
"is_speculative_area": "Y",
|
||||||
|
"is_price_cap": "Y",
|
||||||
|
"receipt_start": "2026-05-15",
|
||||||
|
"receipt_end": "2026-05-19",
|
||||||
|
"match_reasons": ["광역 일치", "자치구 S티어: 강남구 (+25)", "예산 범위"],
|
||||||
|
"eligible_types": ["일반1순위", "특별-신혼부부"],
|
||||||
|
"pblanc_url": "https://example.com/p/1",
|
||||||
|
}]
|
||||||
|
text = format_realestate_matches(matches)
|
||||||
|
assert "디에이치 강남" in text
|
||||||
|
assert "90점" in text
|
||||||
|
assert "강남구" in text
|
||||||
|
assert "2026-05-15" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_realestate_match_compact_when_three_or_more():
|
||||||
|
from app.telegram.realestate_message import format_realestate_matches
|
||||||
|
matches = [
|
||||||
|
{"id": i, "match_score": 90 - i, "house_nm": f"단지{i}", "district": "강남구",
|
||||||
|
"region_name": "서울특별시", "receipt_start": "2026-05-15", "receipt_end": "2026-05-19",
|
||||||
|
"match_reasons": [], "eligible_types": [], "pblanc_url": ""}
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
text = format_realestate_matches(matches)
|
||||||
|
assert "3건" in text or "3" in text
|
||||||
|
for i in range(3):
|
||||||
|
assert f"단지{i}" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_keyboard_single_match_has_bookmark_and_url():
|
||||||
|
from app.telegram.realestate_message import build_match_keyboard
|
||||||
|
matches = [{"id": 42, "pblanc_url": "https://example.com/p/42"}]
|
||||||
|
kb = build_match_keyboard(matches)
|
||||||
|
rows = kb["inline_keyboard"]
|
||||||
|
flat = [b for row in rows for b in row]
|
||||||
|
assert any(b.get("callback_data", "").startswith("realestate_bookmark_42") for b in flat)
|
||||||
|
assert any(b.get("url") == "https://example.com/p/42" for b in flat)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_keyboard_multi_matches_uses_dashboard_link():
|
||||||
|
from app.telegram.realestate_message import build_match_keyboard
|
||||||
|
matches = [{"id": i, "pblanc_url": ""} for i in range(3)]
|
||||||
|
kb = build_match_keyboard(matches)
|
||||||
|
flat = [b for row in kb["inline_keyboard"] for b in row]
|
||||||
|
# 3건 이상이면 [전체 보기] 단일 URL 버튼
|
||||||
|
assert any("전체" in b.get("text", "") for b in flat)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_keyboard_empty_returns_none():
|
||||||
|
from app.telegram.realestate_message import build_match_keyboard
|
||||||
|
assert build_match_keyboard([]) is None
|
||||||
47
agent-office/tests/test_retrospective.py
Normal file
47
agent-office/tests/test_retrospective.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from app.curator.retrospective import build_retrospective, _detect_bias
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_bias_persistent_low():
|
||||||
|
reviews = [
|
||||||
|
{"pattern_delta": "저번호 편향 +1.2 / 합계 -18"},
|
||||||
|
{"pattern_delta": "저번호 편향 +0.8"},
|
||||||
|
{"pattern_delta": "저번호 편향 +1.0 / 홀짝 +0.5"},
|
||||||
|
]
|
||||||
|
assert "저번호" in _detect_bias(reviews)
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_bias_no_persistence():
|
||||||
|
reviews = [
|
||||||
|
{"pattern_delta": "저번호 편향 +1.2"},
|
||||||
|
{"pattern_delta": "고번호 편향 +0.8"},
|
||||||
|
]
|
||||||
|
assert _detect_bias(reviews) == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_build_retrospective_with_data():
|
||||||
|
with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value={
|
||||||
|
"draw_no": 1153, "curator_avg_match": 1.8, "curator_best_tier": "안정",
|
||||||
|
"user_avg_match": 2.0, "user_5plus_prizes": 1, "pattern_delta": "저번호 편향 +1.2",
|
||||||
|
})), patch("app.service_proxy.lotto_reviews_history", new=AsyncMock(return_value=[
|
||||||
|
{"draw_no": 1153, "curator_avg_match": 1.8, "user_avg_match": 2.0, "pattern_delta": "저번호 편향 +1.2"},
|
||||||
|
{"draw_no": 1152, "curator_avg_match": 1.6, "user_avg_match": 1.5, "pattern_delta": "저번호 편향 +0.8"},
|
||||||
|
{"draw_no": 1151, "curator_avg_match": 1.7, "user_avg_match": 1.8, "pattern_delta": "저번호 편향 +1.0"},
|
||||||
|
{"draw_no": 1150, "curator_avg_match": 1.9, "user_avg_match": 2.2, "pattern_delta": ""},
|
||||||
|
])):
|
||||||
|
out = await build_retrospective(1154)
|
||||||
|
assert out["last_draw"]["draw_no"] == 1153
|
||||||
|
assert out["trend_4w"]["curator_avg_4w"] == round((1.8+1.6+1.7+1.9)/4, 2)
|
||||||
|
assert "저번호" in out["trend_4w"]["user_persistent_bias"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_build_retrospective_no_review():
|
||||||
|
with patch("app.service_proxy.lotto_review_by_draw", new=AsyncMock(return_value=None)):
|
||||||
|
out = await build_retrospective(1154)
|
||||||
|
assert out is None
|
||||||
177
agent-office/tests/test_stock_screener_job.py
Normal file
177
agent-office/tests/test_stock_screener_job.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""StockAgent.on_screener_schedule — 평일 16:30 KST 자동 잡 단위 테스트.
|
||||||
|
|
||||||
|
stock HTTP 호출은 service_proxy mock, 텔레그램은 messaging.send_raw mock.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _init_db():
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
os.remove(_TMP)
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def _success_body(asof="2026-05-12"):
|
||||||
|
return {
|
||||||
|
"asof": asof,
|
||||||
|
"mode": "auto",
|
||||||
|
"status": "success",
|
||||||
|
"run_id": 42,
|
||||||
|
"survivors_count": 600,
|
||||||
|
"top_n": 20,
|
||||||
|
"results": [],
|
||||||
|
"telegram_payload": {
|
||||||
|
"chat_target": "default",
|
||||||
|
"parse_mode": "MarkdownV2",
|
||||||
|
"text": "*KRX 강세주 스크리너* test body",
|
||||||
|
},
|
||||||
|
"warnings": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _holiday_body(asof="2026-05-05"):
|
||||||
|
return {
|
||||||
|
"asof": asof,
|
||||||
|
"mode": "auto",
|
||||||
|
"status": "skipped_holiday",
|
||||||
|
"run_id": None,
|
||||||
|
"survivors_count": None,
|
||||||
|
"top_n": 0,
|
||||||
|
"results": [],
|
||||||
|
"telegram_payload": None,
|
||||||
|
"warnings": [f"{asof} is a holiday — skipped"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_screener_success_sends_markdownv2_telegram():
|
||||||
|
from app.agents.stock import StockAgent
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import messaging
|
||||||
|
|
||||||
|
fake_snap = AsyncMock(return_value={"status": "ok"})
|
||||||
|
fake_run = AsyncMock(return_value=_success_body())
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 7777})
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||||
|
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
||||||
|
patch.object(messaging, "send_raw", fake_send):
|
||||||
|
agent = StockAgent()
|
||||||
|
asyncio.run(agent.on_screener_schedule())
|
||||||
|
|
||||||
|
fake_snap.assert_awaited_once()
|
||||||
|
fake_run.assert_awaited_once_with(mode="auto")
|
||||||
|
fake_send.assert_awaited_once()
|
||||||
|
args, kwargs = fake_send.call_args
|
||||||
|
# 첫 인자(text) 또는 kwargs로 전달
|
||||||
|
text = args[0] if args else kwargs.get("text")
|
||||||
|
assert "KRX 강세주 스크리너" in text
|
||||||
|
assert kwargs.get("parse_mode") == "MarkdownV2"
|
||||||
|
assert agent.state == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_screener_holiday_skips_telegram():
|
||||||
|
from app.agents.stock import StockAgent
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import messaging
|
||||||
|
|
||||||
|
fake_snap = AsyncMock(return_value={"status": "skipped_weekend"})
|
||||||
|
fake_run = AsyncMock(return_value=_holiday_body())
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||||
|
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
||||||
|
patch.object(messaging, "send_raw", fake_send):
|
||||||
|
agent = StockAgent()
|
||||||
|
asyncio.run(agent.on_screener_schedule())
|
||||||
|
|
||||||
|
fake_run.assert_awaited_once()
|
||||||
|
# 휴일이면 텔레그램 미발신
|
||||||
|
fake_send.assert_not_awaited()
|
||||||
|
assert agent.state == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_screener_snapshot_failure_still_runs_screener():
|
||||||
|
"""스냅샷 실패는 경고만 남기고 screener 호출은 계속됨."""
|
||||||
|
from app.agents.stock import StockAgent
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import messaging
|
||||||
|
|
||||||
|
fake_snap = AsyncMock(side_effect=RuntimeError("snapshot upstream down"))
|
||||||
|
fake_run = AsyncMock(return_value=_success_body())
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 8888})
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||||
|
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
||||||
|
patch.object(messaging, "send_raw", fake_send):
|
||||||
|
agent = StockAgent()
|
||||||
|
asyncio.run(agent.on_screener_schedule())
|
||||||
|
|
||||||
|
fake_snap.assert_awaited_once()
|
||||||
|
fake_run.assert_awaited_once_with(mode="auto")
|
||||||
|
fake_send.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_screener_run_failure_notifies_operator():
|
||||||
|
"""screener/run 실패 시 운영자 알림 텔레그램 발송."""
|
||||||
|
from app.agents.stock import StockAgent
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import messaging
|
||||||
|
|
||||||
|
fake_snap = AsyncMock(return_value={"status": "ok"})
|
||||||
|
fake_run = AsyncMock(side_effect=RuntimeError("stock 500"))
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||||
|
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
||||||
|
patch.object(messaging, "send_raw", fake_send):
|
||||||
|
agent = StockAgent()
|
||||||
|
asyncio.run(agent.on_screener_schedule())
|
||||||
|
|
||||||
|
# 운영자 알림 1회는 호출
|
||||||
|
assert fake_send.await_count == 1
|
||||||
|
args, kwargs = fake_send.call_args
|
||||||
|
text = args[0] if args else kwargs.get("text")
|
||||||
|
assert "스크리너 실패" in text
|
||||||
|
assert agent.state == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_screener_unexpected_status_treated_as_failure():
|
||||||
|
from app.agents.stock import StockAgent
|
||||||
|
from app import service_proxy
|
||||||
|
from app.telegram import messaging
|
||||||
|
|
||||||
|
fake_snap = AsyncMock(return_value={"status": "ok"})
|
||||||
|
fake_run = AsyncMock(return_value={"status": "weird", "asof": "2026-05-12"})
|
||||||
|
fake_send = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||||
|
|
||||||
|
with patch.object(service_proxy, "refresh_screener_snapshot", fake_snap), \
|
||||||
|
patch.object(service_proxy, "run_stock_screener", fake_run), \
|
||||||
|
patch.object(messaging, "send_raw", fake_send):
|
||||||
|
agent = StockAgent()
|
||||||
|
asyncio.run(agent.on_screener_schedule())
|
||||||
|
|
||||||
|
# 운영자 알림 1회 + screener payload 미발송
|
||||||
|
assert fake_send.await_count == 1
|
||||||
|
args, kwargs = fake_send.call_args
|
||||||
|
text = args[0] if args else kwargs.get("text")
|
||||||
|
assert "스크리너 실패" in text
|
||||||
123
agent-office/tests/test_sync_evolver_activity.py
Normal file
123
agent-office/tests/test_sync_evolver_activity.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# agent-office/tests/test_sync_evolver_activity.py
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import gc
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
_fd, _TMP = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(_fd)
|
||||||
|
os.unlink(_TMP)
|
||||||
|
os.environ["AGENT_OFFICE_DB_PATH"] = _TMP
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app import db
|
||||||
|
db.DB_PATH = _TMP
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db():
|
||||||
|
# Re-patch DB_PATH at the start of every test (cross-file isolation)
|
||||||
|
db.DB_PATH = _TMP
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
os.remove(_TMP)
|
||||||
|
db.init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
if os.path.exists(_TMP):
|
||||||
|
try:
|
||||||
|
os.remove(_TMP)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _today_dow_clamped():
|
||||||
|
"""오늘의 weekday() (일요일=6은 5로 clamp)."""
|
||||||
|
KST = timezone(timedelta(hours=9))
|
||||||
|
dow = datetime.now(KST).weekday()
|
||||||
|
return 5 if dow == 6 else dow
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_status_with_picks(dow_with_picks):
|
||||||
|
async def fake():
|
||||||
|
return {
|
||||||
|
"week_start": "2026-05-18",
|
||||||
|
"current_base": [0.2] * 5,
|
||||||
|
"trials": [
|
||||||
|
{
|
||||||
|
"id": 100 + i,
|
||||||
|
"day_of_week": i,
|
||||||
|
"weight": [0.2] * 5,
|
||||||
|
"source": "perturb",
|
||||||
|
"picks": ([
|
||||||
|
{"id": j, "numbers": [1,2,3,4,5,6], "meta_score": 0.5}
|
||||||
|
for j in range(5)
|
||||||
|
] if i == dow_with_picks else []),
|
||||||
|
}
|
||||||
|
for i in range(6)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_evolver_activity_creates_apply_task(monkeypatch):
|
||||||
|
"""오늘 trial에 picks가 있으면 evolver_apply task 1개 생성."""
|
||||||
|
from app.agents.lotto import LottoAgent
|
||||||
|
from app import service_proxy
|
||||||
|
|
||||||
|
dow = _today_dow_clamped()
|
||||||
|
monkeypatch.setattr(service_proxy, "lotto_evolver_status", _fake_status_with_picks(dow))
|
||||||
|
|
||||||
|
agent = LottoAgent()
|
||||||
|
await agent.sync_evolver_activity()
|
||||||
|
|
||||||
|
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
|
||||||
|
assert len(apply_tasks) == 1
|
||||||
|
assert apply_tasks[0]["result_data"]["n_picks"] == 5
|
||||||
|
assert apply_tasks[0]["input_data"]["day_of_week"] == dow
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_evolver_activity_idempotent(monkeypatch):
|
||||||
|
"""같은 날 두 번 호출해도 task는 1개만 (멱등)."""
|
||||||
|
from app.agents.lotto import LottoAgent
|
||||||
|
from app import service_proxy
|
||||||
|
|
||||||
|
dow = _today_dow_clamped()
|
||||||
|
monkeypatch.setattr(service_proxy, "lotto_evolver_status", _fake_status_with_picks(dow))
|
||||||
|
|
||||||
|
agent = LottoAgent()
|
||||||
|
await agent.sync_evolver_activity()
|
||||||
|
await agent.sync_evolver_activity()
|
||||||
|
|
||||||
|
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
|
||||||
|
assert len(apply_tasks) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_evolver_activity_no_picks_no_task(monkeypatch):
|
||||||
|
"""오늘 trial에 picks가 없으면 task 생성하지 않음."""
|
||||||
|
from app.agents.lotto import LottoAgent
|
||||||
|
from app import service_proxy
|
||||||
|
|
||||||
|
async def fake_status():
|
||||||
|
return {
|
||||||
|
"week_start": "2026-05-18",
|
||||||
|
"current_base": [0.2] * 5,
|
||||||
|
"trials": [
|
||||||
|
{"id": 100 + i, "day_of_week": i, "weight": [0.2]*5,
|
||||||
|
"source": "perturb", "picks": []}
|
||||||
|
for i in range(6)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(service_proxy, "lotto_evolver_status", fake_status)
|
||||||
|
|
||||||
|
agent = LottoAgent()
|
||||||
|
await agent.sync_evolver_activity()
|
||||||
|
|
||||||
|
apply_tasks = db.get_agent_tasks("lotto", task_type="evolver_apply", days=1)
|
||||||
|
assert len(apply_tasks) == 0
|
||||||
70
agent-office/tests/test_tarot_db.py
Normal file
70
agent-office/tests/test_tarot_db.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db as db_module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(monkeypatch, tmp_path):
|
||||||
|
db_file = tmp_path / "test_tarot.db"
|
||||||
|
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||||
|
db_module.init_db()
|
||||||
|
yield
|
||||||
|
if db_file.exists():
|
||||||
|
db_file.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_get_tarot_reading():
|
||||||
|
rid = db_module.save_tarot_reading({
|
||||||
|
"spread_type": "three_card",
|
||||||
|
"category": "연애",
|
||||||
|
"question": "Q",
|
||||||
|
"cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}],
|
||||||
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"},
|
||||||
|
"model": "claude-sonnet-4-6",
|
||||||
|
"tokens_in": 100, "tokens_out": 200, "cost_usd": 0.005,
|
||||||
|
"confidence": "medium",
|
||||||
|
})
|
||||||
|
assert rid > 0
|
||||||
|
row = db_module.get_tarot_reading(rid)
|
||||||
|
assert row["id"] == rid
|
||||||
|
assert row["category"] == "연애"
|
||||||
|
assert row["interpretation_json"]["summary"] == "S"
|
||||||
|
assert row["favorite"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tarot_readings_filters_and_pagination():
|
||||||
|
for cat in ["연애", "연애", "재물"]:
|
||||||
|
db_module.save_tarot_reading({
|
||||||
|
"spread_type": "three_card", "category": cat, "question": "Q",
|
||||||
|
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "low"},
|
||||||
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||||||
|
})
|
||||||
|
res = db_module.list_tarot_readings(page=1, size=10, category="연애")
|
||||||
|
assert res["total"] == 2
|
||||||
|
assert all(r["category"] == "연애" for r in res["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_tarot_reading_favorite_and_note():
|
||||||
|
rid = db_module.save_tarot_reading({
|
||||||
|
"spread_type": "one_card", "category": None, "question": None,
|
||||||
|
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
|
||||||
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
|
||||||
|
})
|
||||||
|
db_module.update_tarot_reading(rid, favorite=True, note="기억하고 싶음")
|
||||||
|
row = db_module.get_tarot_reading(rid)
|
||||||
|
assert row["favorite"] == 1
|
||||||
|
assert row["note"] == "기억하고 싶음"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_tarot_reading():
|
||||||
|
rid = db_module.save_tarot_reading({
|
||||||
|
"spread_type": "one_card", "category": None, "question": None,
|
||||||
|
"cards": [], "interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "", "warning": None, "confidence": "high"},
|
||||||
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "high",
|
||||||
|
})
|
||||||
|
db_module.delete_tarot_reading(rid)
|
||||||
|
assert db_module.get_tarot_reading(rid) is None
|
||||||
113
agent-office/tests/test_tarot_pipeline.py
Normal file
113
agent-office/tests/test_tarot_pipeline.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from httpx import Response
|
||||||
|
|
||||||
|
from app.tarot import pipeline as p
|
||||||
|
from app.models import TarotInterpretRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_response_text():
|
||||||
|
return json.dumps({
|
||||||
|
"summary": "S",
|
||||||
|
"cards": [
|
||||||
|
{"position": "과거", "card": "the-fool", "reversed": False,
|
||||||
|
"interpretation": "i", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
{"position": "현재", "card": "the-lovers", "reversed": True,
|
||||||
|
"interpretation": "i", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
{"position": "미래", "card": "ten-of-cups", "reversed": False,
|
||||||
|
"interpretation": "i", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
],
|
||||||
|
"interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "."}],
|
||||||
|
"advice": "A", "warning": None, "confidence": "medium",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_resp(text, in_tok=100, out_tok=200):
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": text}],
|
||||||
|
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _req():
|
||||||
|
return TarotInterpretRequest(
|
||||||
|
spread_type="three_card",
|
||||||
|
category="연애",
|
||||||
|
question="Q",
|
||||||
|
cards=[
|
||||||
|
{"position": "과거", "card_id": "the-fool", "reversed": False},
|
||||||
|
{"position": "현재", "card_id": "the-lovers", "reversed": True},
|
||||||
|
{"position": "미래", "card_id": "ten-of-cups", "reversed": False},
|
||||||
|
],
|
||||||
|
cards_reference="REFERENCE",
|
||||||
|
context_meta={"major_minor_ratio": "2:1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_happy_path(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(_valid_response_text())))
|
||||||
|
out = await p.interpret(_req())
|
||||||
|
assert out["interpretation_json"]["confidence"] == "medium"
|
||||||
|
assert out["tokens_in"] == 100
|
||||||
|
assert out["tokens_out"] == 200
|
||||||
|
assert out["reroll_count"] == 0
|
||||||
|
assert out["cost_usd"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_codeblock_strip(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
wrapped = "```json\n" + _valid_response_text() + "\n```"
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(wrapped)))
|
||||||
|
out = await p.interpret(_req())
|
||||||
|
assert out["interpretation_json"]["summary"] == "S"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_reroll_on_validation_fail(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
bad = json.loads(_valid_response_text())
|
||||||
|
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||||
|
bad_text = json.dumps(bad)
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
route = mock.post("/v1/messages")
|
||||||
|
route.side_effect = [
|
||||||
|
Response(200, json=_claude_resp(bad_text)),
|
||||||
|
Response(200, json=_claude_resp(_valid_response_text())),
|
||||||
|
]
|
||||||
|
out = await p.interpret(_req())
|
||||||
|
assert out["reroll_count"] == 1
|
||||||
|
assert out["interpretation_json"]["cards"][0]["evidence"]["card_meaning_used"] == "k"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_raises_when_both_attempts_fail(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
bad = json.loads(_valid_response_text())
|
||||||
|
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||||
|
bad_text = json.dumps(bad)
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(bad_text)))
|
||||||
|
with pytest.raises(p.TarotError):
|
||||||
|
await p.interpret(_req())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_raises_when_api_key_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "")
|
||||||
|
with pytest.raises(p.TarotError):
|
||||||
|
await p.interpret(_req())
|
||||||
|
|
||||||
|
|
||||||
|
def test_calc_cost():
|
||||||
|
assert p.calc_cost(1_000_000, 0) == pytest.approx(3.0)
|
||||||
|
assert p.calc_cost(0, 1_000_000) == pytest.approx(15.0)
|
||||||
|
assert p.calc_cost(500_000, 500_000) == pytest.approx(9.0)
|
||||||
86
agent-office/tests/test_tarot_routes.py
Normal file
86
agent-office/tests/test_tarot_routes.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import db as db_module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(monkeypatch, tmp_path):
|
||||||
|
db_file = tmp_path / "test_routes.db"
|
||||||
|
monkeypatch.setattr(db_module, "DB_PATH", str(db_file))
|
||||||
|
db_module.init_db()
|
||||||
|
from app.main import app
|
||||||
|
yield app
|
||||||
|
|
||||||
|
|
||||||
|
def test_interpret_calls_pipeline(monkeypatch, fresh_db):
|
||||||
|
async def fake_interpret(req):
|
||||||
|
return {
|
||||||
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "high"},
|
||||||
|
"model": "claude-sonnet-4-6", "tokens_in": 100, "tokens_out": 200,
|
||||||
|
"cost_usd": 0.005, "latency_ms": 1234, "reroll_count": 0,
|
||||||
|
}
|
||||||
|
from app.tarot import pipeline
|
||||||
|
monkeypatch.setattr(pipeline, "interpret", fake_interpret)
|
||||||
|
client = TestClient(fresh_db)
|
||||||
|
r = client.post("/api/agent-office/tarot/interpret", json={
|
||||||
|
"spread_type": "one_card",
|
||||||
|
"category": "일반",
|
||||||
|
"question": "Q",
|
||||||
|
"cards": [{"position": "오늘", "card_id": "the-fool", "reversed": False}],
|
||||||
|
"cards_reference": "REF",
|
||||||
|
"context_meta": {},
|
||||||
|
})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["interpretation_json"]["confidence"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_list(fresh_db):
|
||||||
|
client = TestClient(fresh_db)
|
||||||
|
save = client.post("/api/agent-office/tarot/readings", json={
|
||||||
|
"spread_type": "three_card", "category": "연애", "question": "Q",
|
||||||
|
"cards": [{"position": "과거", "card_id": "the-fool", "reversed": False}],
|
||||||
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "medium"},
|
||||||
|
"model": "claude-sonnet-4-6", "tokens_in": 1, "tokens_out": 2, "cost_usd": 0.01,
|
||||||
|
"confidence": "medium",
|
||||||
|
})
|
||||||
|
assert save.status_code == 200, save.text
|
||||||
|
rid = save.json()["id"]
|
||||||
|
lst = client.get("/api/agent-office/tarot/readings?page=1&size=10")
|
||||||
|
assert lst.json()["total"] == 1
|
||||||
|
assert lst.json()["items"][0]["id"] == rid
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_favorite(fresh_db):
|
||||||
|
client = TestClient(fresh_db)
|
||||||
|
save = client.post("/api/agent-office/tarot/readings", json={
|
||||||
|
"spread_type": "one_card", "cards": [],
|
||||||
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "low"},
|
||||||
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||||||
|
})
|
||||||
|
rid = save.json()["id"]
|
||||||
|
p = client.patch(f"/api/agent-office/tarot/readings/{rid}", json={"favorite": True})
|
||||||
|
assert p.status_code == 200
|
||||||
|
g = client.get(f"/api/agent-office/tarot/readings/{rid}")
|
||||||
|
assert g.json()["favorite"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete(fresh_db):
|
||||||
|
client = TestClient(fresh_db)
|
||||||
|
save = client.post("/api/agent-office/tarot/readings", json={
|
||||||
|
"spread_type": "one_card", "cards": [],
|
||||||
|
"interpretation_json": {"summary": "S", "cards": [], "interactions": [], "advice": "A", "warning": None, "confidence": "low"},
|
||||||
|
"model": "x", "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0, "confidence": "low",
|
||||||
|
})
|
||||||
|
rid = save.json()["id"]
|
||||||
|
d = client.delete(f"/api/agent-office/tarot/readings/{rid}")
|
||||||
|
assert d.status_code == 200
|
||||||
|
g = client.get(f"/api/agent-office/tarot/readings/{rid}")
|
||||||
|
assert g.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_missing_reading_404(fresh_db):
|
||||||
|
client = TestClient(fresh_db)
|
||||||
|
r = client.get("/api/agent-office/tarot/readings/99999")
|
||||||
|
assert r.status_code == 404
|
||||||
75
agent-office/tests/test_tarot_schema.py
Normal file
75
agent-office/tests/test_tarot_schema.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.tarot.schema import validate_interpretation
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_three():
|
||||||
|
return {
|
||||||
|
"summary": "S",
|
||||||
|
"cards": [
|
||||||
|
{"position": "과거", "card": "the-fool", "reversed": False,
|
||||||
|
"interpretation": "...", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
{"position": "현재", "card": "the-lovers", "reversed": True,
|
||||||
|
"interpretation": "...", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
{"position": "미래", "card": "ten-of-cups", "reversed": False,
|
||||||
|
"interpretation": "...", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "키워드", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
],
|
||||||
|
"interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "..."}],
|
||||||
|
"advice": "A",
|
||||||
|
"warning": None,
|
||||||
|
"confidence": "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_three_card_passes():
|
||||||
|
ok, msg = validate_interpretation(_valid_three(), "three_card")
|
||||||
|
assert ok, msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_evidence_fails():
|
||||||
|
bad = _valid_three()
|
||||||
|
del bad["cards"][0]["evidence"]
|
||||||
|
ok, msg = validate_interpretation(bad, "three_card")
|
||||||
|
assert not ok
|
||||||
|
assert "evidence" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_card_meaning_used_fails():
|
||||||
|
bad = _valid_three()
|
||||||
|
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||||
|
ok, msg = validate_interpretation(bad, "three_card")
|
||||||
|
assert not ok
|
||||||
|
assert "card_meaning_used" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_card_requires_interactions():
|
||||||
|
bad = _valid_three()
|
||||||
|
bad["interactions"] = []
|
||||||
|
ok, msg = validate_interpretation(bad, "three_card")
|
||||||
|
assert not ok
|
||||||
|
assert "interactions" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_card_accepts_empty_interactions():
|
||||||
|
one = {
|
||||||
|
"summary": "S",
|
||||||
|
"cards": [{"position": "오늘", "card": "the-fool", "reversed": False,
|
||||||
|
"interpretation": "...", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}}],
|
||||||
|
"interactions": [],
|
||||||
|
"advice": "A",
|
||||||
|
"warning": None,
|
||||||
|
"confidence": "high",
|
||||||
|
}
|
||||||
|
ok, msg = validate_interpretation(one, "one_card")
|
||||||
|
assert ok, msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_confidence_fails():
|
||||||
|
bad = _valid_three()
|
||||||
|
bad["confidence"] = "very high"
|
||||||
|
ok, msg = validate_interpretation(bad, "three_card")
|
||||||
|
assert not ok
|
||||||
44
agent-office/tests/test_telegram_lotto_format.py
Normal file
44
agent-office/tests/test_telegram_lotto_format.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from app.notifiers.telegram_lotto import _format_briefing, _format_prize_alert
|
||||||
|
|
||||||
|
|
||||||
|
def test_briefing_with_retrospective():
|
||||||
|
payload = {
|
||||||
|
"draw_no": 1154,
|
||||||
|
"confidence": 72,
|
||||||
|
"narrative": {
|
||||||
|
"headline": "안정 +1, 콜드 누적 보강",
|
||||||
|
"summary_3lines": ["a", "b", "c"],
|
||||||
|
"retrospective": "너 2.0 / 나 1.8 — 저번호 편향",
|
||||||
|
},
|
||||||
|
"picks": {
|
||||||
|
"core": [
|
||||||
|
{"risk_tag": "안정"}, {"risk_tag": "안정"}, {"risk_tag": "안정"},
|
||||||
|
{"risk_tag": "균형"}, {"risk_tag": "공격"},
|
||||||
|
],
|
||||||
|
"bonus": [], "extended": [], "pool": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
text = _format_briefing(payload)
|
||||||
|
assert "1154회" in text
|
||||||
|
assert "신뢰도 72" in text
|
||||||
|
assert "안정 3" in text
|
||||||
|
assert "회고: 너 2.0" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_briefing_without_retrospective():
|
||||||
|
payload = {
|
||||||
|
"draw_no": 1, "confidence": 50,
|
||||||
|
"narrative": {"headline": "h", "summary_3lines": ["a","b","c"], "retrospective": ""},
|
||||||
|
"picks": {"core": [{"risk_tag":"안정"}]*5, "bonus":[],"extended":[],"pool":[]},
|
||||||
|
}
|
||||||
|
text = _format_briefing(payload)
|
||||||
|
assert "회고" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_prize_alert():
|
||||||
|
text = _format_prize_alert({"draw_no": 1154, "match_count": 5, "numbers": [3,11,17,25,33,8]})
|
||||||
|
assert "5개 일치" in text
|
||||||
|
assert "3, 11, 17, 25, 33, 8" in text
|
||||||
1328
backend/app/db.py
1328
backend/app/db.py
File diff suppressed because it is too large
Load Diff
6
deployer/.dockerignore
Normal file
6
deployer/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Docker CE CLI + Compose Plugin (공식 저장소에서 설치)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git rsync ca-certificates curl \
|
git rsync ca-certificates curl util-linux gnupg \
|
||||||
docker.io \
|
&& install -m 0755 -d /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg \
|
||||||
|
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||||
|
https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import os, hmac, hashlib, subprocess
|
import os, hmac, hashlib, subprocess, threading
|
||||||
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# 로깅 설정
|
logging.basicConfig(
|
||||||
logging.basicConfig(level=logging.INFO)
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S %Z",
|
||||||
|
)
|
||||||
logger = logging.getLogger("deployer")
|
logger = logging.getLogger("deployer")
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
SECRET = os.getenv("WEBHOOK_SECRET", "")
|
SECRET = os.getenv("WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
|
if not SECRET:
|
||||||
|
logger.warning("WEBHOOK_SECRET is not set! All webhooks will be rejected.")
|
||||||
|
|
||||||
|
_deploy_lock = threading.Lock()
|
||||||
|
|
||||||
def verify(sig: str, body: bytes) -> bool:
|
def verify(sig: str, body: bytes) -> bool:
|
||||||
if not SECRET or not sig:
|
if not SECRET or not sig:
|
||||||
return False
|
return False
|
||||||
@@ -18,19 +27,30 @@ def verify(sig: str, body: bytes) -> bool:
|
|||||||
return any(hmac.compare_digest(sig, c) for c in candidates)
|
return any(hmac.compare_digest(sig, c) for c in candidates)
|
||||||
|
|
||||||
def run_deploy_script():
|
def run_deploy_script():
|
||||||
"""배포 스크립트를 백그라운드에서 실행하고 로그를 남김"""
|
"""배포 스크립트를 백그라운드에서 실행 (동시 실행 방지)"""
|
||||||
logger.info("Starting deployment script...")
|
if not _deploy_lock.acquire(blocking=False):
|
||||||
|
logger.info("Deploy already in progress, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 타임아웃 10분 설정
|
logger.info("Starting deployment script...")
|
||||||
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
|
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
|
||||||
|
|
||||||
if p.returncode == 0:
|
if p.returncode == 0:
|
||||||
logger.info(f"Deployment SUCCESS:\n{p.stdout}")
|
logger.info(f"Deployment SUCCESS:\n{p.stdout}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}")
|
logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}")
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Deployment TIMEOUT (10 min exceeded)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Exception during deployment: {e}")
|
logger.exception(f"Exception during deployment: {e}")
|
||||||
|
finally:
|
||||||
|
_deploy_lock.release()
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "healthy", "service": "deployer"}
|
||||||
|
|
||||||
@app.post("/webhook")
|
@app.post("/webhook")
|
||||||
async def webhook(req: Request, background_tasks: BackgroundTasks):
|
async def webhook(req: Request, background_tasks: BackgroundTasks):
|
||||||
@@ -45,8 +65,15 @@ async def webhook(req: Request, background_tasks: BackgroundTasks):
|
|||||||
if not verify(sig, body):
|
if not verify(sig, body):
|
||||||
raise HTTPException(401, "bad signature")
|
raise HTTPException(401, "bad signature")
|
||||||
|
|
||||||
|
# 동시 배포 방지: 이미 진행 중이면 503 반환
|
||||||
|
if _deploy_lock.locked():
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={"ok": False, "message": "Deploy already in progress"},
|
||||||
|
)
|
||||||
|
|
||||||
# ✅ 비동기 실행: Gitea에게는 즉시 OK 응답을 주고, 배포는 뒤에서 실행
|
# ✅ 비동기 실행: Gitea에게는 즉시 OK 응답을 주고, 배포는 뒤에서 실행
|
||||||
background_tasks.add_task(run_deploy_script)
|
background_tasks.add_task(run_deploy_script)
|
||||||
|
|
||||||
return {"ok": True, "message": "Deployment started in background"}
|
return {"ok": True, "message": "Deployment started in background"}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
version: "3.8"
|
name: webpage
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
lotto:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./lotto
|
||||||
args:
|
args:
|
||||||
APP_VERSION: ${APP_VERSION:-dev}
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
container_name: lotto-backend
|
container_name: lotto
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "18000:8000"
|
- "18000:8000"
|
||||||
@@ -16,21 +16,41 @@ services:
|
|||||||
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
||||||
volumes:
|
volumes:
|
||||||
- ${RUNTIME_PATH}/data:/app/data
|
- ${RUNTIME_PATH}/data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
stock-lab:
|
stock:
|
||||||
build:
|
build:
|
||||||
context: ./stock-lab
|
context: ./stock
|
||||||
args:
|
args:
|
||||||
APP_VERSION: ${APP_VERSION:-dev}
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
container_name: stock-lab
|
container_name: stock
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "18500:8000"
|
- "18500:8000"
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
- WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000}
|
- WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000}
|
||||||
|
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||||
|
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash}
|
||||||
|
- ADMIN_API_KEY=${ADMIN_API_KEY:-}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-haiku-4-5-20251001}
|
||||||
|
- LLM_PROVIDER=${LLM_PROVIDER:-claude}
|
||||||
|
- OLLAMA_URL=${OLLAMA_URL:-http://192.168.45.59:11435}
|
||||||
|
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:14b}
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
- WEBAI_API_KEY=${WEBAI_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ${STOCK_DATA_PATH:-./data/stock}:/app/data
|
- ${RUNTIME_PATH}/data/stock:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
music-lab:
|
music-lab:
|
||||||
build:
|
build:
|
||||||
@@ -43,8 +63,215 @@ services:
|
|||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
|
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
|
||||||
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
|
- MUSIC_MEDIA_BASE=${MUSIC_MEDIA_BASE:-/media/music}
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
- PEXELS_API_KEY=${PEXELS_API_KEY:-}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- YOUTUBE_OAUTH_CLIENT_ID=${YOUTUBE_OAUTH_CLIENT_ID:-}
|
||||||
|
- YOUTUBE_OAUTH_CLIENT_SECRET=${YOUTUBE_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- YOUTUBE_OAUTH_REDIRECT_URI=${YOUTUBE_OAUTH_REDIRECT_URI:-}
|
||||||
|
- CLAUDE_HAIKU_MODEL=${CLAUDE_HAIKU_MODEL:-claude-haiku-4-5-20251001}
|
||||||
|
- CLAUDE_SONNET_MODEL=${CLAUDE_SONNET_MODEL:-claude-sonnet-4-6}
|
||||||
|
- VIDEO_DATA_DIR=${VIDEO_DATA_DIR:-/app/data/videos}
|
||||||
|
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL:-}
|
||||||
|
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
||||||
|
- NAS_MUSIC_ROOT=${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||||
|
- MUSIC_RENDER_URL=${MUSIC_RENDER_URL:-http://192.168.45.59:18711}
|
||||||
volumes:
|
volumes:
|
||||||
- ${MUSIC_DATA_PATH:-./data/music}:/app/data
|
- ${RUNTIME_PATH}/data/music:/app/data
|
||||||
|
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
video-lab:
|
||||||
|
build:
|
||||||
|
context: ./video-lab
|
||||||
|
container_name: video-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18801:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||||
|
- VIDEO_DATA_DIR=/app/data
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH}/data/video:/app/data
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
image-lab:
|
||||||
|
build: ./image-lab
|
||||||
|
container_name: image-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18802:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||||
|
- IMAGE_DATA_DIR=/app/data
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH}/data/image:/app/data
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
insta-lab:
|
||||||
|
build:
|
||||||
|
context: ./insta-lab
|
||||||
|
container_name: insta-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18700:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- ANTHROPIC_MODEL_HAIKU=${ANTHROPIC_MODEL_HAIKU:-claude-haiku-4-5-20251001}
|
||||||
|
- ANTHROPIC_MODEL_SONNET=${ANTHROPIC_MODEL_SONNET:-claude-sonnet-4-6}
|
||||||
|
- NAVER_CLIENT_ID=${NAVER_CLIENT_ID:-}
|
||||||
|
- NAVER_CLIENT_SECRET=${NAVER_CLIENT_SECRET:-}
|
||||||
|
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
|
||||||
|
- INSTA_DATA_PATH=/app/data
|
||||||
|
- CARD_TEMPLATE_DIR=/app/app/templates
|
||||||
|
- INSTA_DEFAULT_THEME=${INSTA_DEFAULT_THEME:-default}
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH}/data/insta:/app/data
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
realestate-lab:
|
||||||
|
build:
|
||||||
|
context: ./realestate-lab
|
||||||
|
container_name: realestate-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18800:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-}
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
- AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000}
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH}/data/realestate:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
agent-office:
|
||||||
|
build:
|
||||||
|
context: ./agent-office
|
||||||
|
container_name: agent-office
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18900:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
- STOCK_URL=http://stock:8000
|
||||||
|
- MUSIC_LAB_URL=http://music-lab:8000
|
||||||
|
- INSTA_LAB_URL=http://insta-lab:8000
|
||||||
|
- REALESTATE_LAB_URL=http://realestate-lab:8000
|
||||||
|
- REALESTATE_DASHBOARD_URL=${REALESTATE_DASHBOARD_URL:-http://localhost:8080/realestate}
|
||||||
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||||
|
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||||
|
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
|
||||||
|
- TELEGRAM_WIFE_CHAT_ID=${TELEGRAM_WIFE_CHAT_ID:-}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- CLAUDE_HAIKU_MODEL=${CLAUDE_HAIKU_MODEL:-claude-haiku-4-5-20251001}
|
||||||
|
- CLAUDE_SONNET_MODEL=${CLAUDE_SONNET_MODEL:-claude-sonnet-4-6}
|
||||||
|
- LOTTO_BACKEND_URL=${LOTTO_BACKEND_URL:-http://lotto:8000}
|
||||||
|
- LOTTO_CURATOR_MODEL=${LOTTO_CURATOR_MODEL:-claude-sonnet-4-5}
|
||||||
|
- CONVERSATION_MODEL=${CONVERSATION_MODEL:-claude-haiku-4-5-20251001}
|
||||||
|
- CONVERSATION_HISTORY_LIMIT=${CONVERSATION_HISTORY_LIMIT:-20}
|
||||||
|
- CONVERSATION_RATE_PER_MIN=${CONVERSATION_RATE_PER_MIN:-6}
|
||||||
|
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
|
||||||
|
depends_on:
|
||||||
|
- stock
|
||||||
|
- music-lab
|
||||||
|
- insta-lab
|
||||||
|
- realestate-lab
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
personal:
|
||||||
|
build:
|
||||||
|
context: ./personal
|
||||||
|
container_name: personal
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18850:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD:-}
|
||||||
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH:-.}/data/personal:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
packs-lab:
|
||||||
|
build:
|
||||||
|
context: ./packs-lab
|
||||||
|
container_name: packs-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18950:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- DSM_HOST=${DSM_HOST:-}
|
||||||
|
- DSM_USER=${DSM_USER:-}
|
||||||
|
- DSM_PASS=${DSM_PASS:-}
|
||||||
|
- DSM_VERIFY_SSL=${DSM_VERIFY_SSL:-true}
|
||||||
|
- BACKEND_HMAC_SECRET=${BACKEND_HMAC_SECRET:-}
|
||||||
|
- SUPABASE_URL=${SUPABASE_URL:-}
|
||||||
|
- SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY:-}
|
||||||
|
- UPLOAD_TOKEN_TTL_SEC=${UPLOAD_TOKEN_TTL_SEC:-1800}
|
||||||
|
- PACK_BASE_DIR=${PACK_BASE_DIR:-/app/data/packs}
|
||||||
|
- PACK_HOST_DIR=${PACK_HOST_DIR:-${PACK_DATA_PATH:-./data/packs}}
|
||||||
|
volumes:
|
||||||
|
- ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
travel-proxy:
|
travel-proxy:
|
||||||
build: ./travel-proxy
|
build: ./travel-proxy
|
||||||
@@ -52,22 +279,39 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "${PUID}:${PGID}"
|
user: "${PUID}:${PGID}"
|
||||||
ports:
|
ports:
|
||||||
- "19000:8000" # 내부 확인용
|
- "19000:8000"
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
- TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel}
|
- TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel}
|
||||||
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
|
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
|
||||||
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
|
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
|
||||||
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
|
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
volumes:
|
volumes:
|
||||||
- ${PHOTO_PATH}:/data/travel:ro
|
- ${PHOTO_PATH}:/data/travel:ro
|
||||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: lotto-frontend
|
container_name: frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- lotto
|
||||||
|
- stock
|
||||||
|
- music-lab
|
||||||
|
- insta-lab
|
||||||
|
- realestate-lab
|
||||||
|
- agent-office
|
||||||
|
- personal
|
||||||
|
- packs-lab
|
||||||
|
- travel-proxy
|
||||||
|
- video-lab
|
||||||
|
- image-lab
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -75,20 +319,46 @@ services:
|
|||||||
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- ${PHOTO_PATH}:/data/travel:ro
|
- ${PHOTO_PATH}:/data/travel:ro
|
||||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||||
- ${MUSIC_DATA_PATH:-./data/music}:/data/music:ro
|
- ${RUNTIME_PATH}/data/music:/data/music:ro
|
||||||
|
- ${RUNTIME_PATH}/data/videos:/data/videos:ro
|
||||||
|
- ${RUNTIME_PATH}/data/video:/data/video:ro
|
||||||
|
- ${RUNTIME_PATH}/data/insta/insta_cards:/data/insta_cards:ro
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
deployer:
|
deployer:
|
||||||
build: ./deployer
|
build: ./deployer
|
||||||
container_name: webpage-deployer
|
container_name: webpage-deployer
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "19010:9000" # 외부 노출 필요 없으면 내부만 (리버스프록시로 /webhook만 노출 추천)
|
- "127.0.0.1:19010:9000"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||||
|
- PUID=${PUID:-1026}
|
||||||
|
- PGID=${PGID:-100}
|
||||||
volumes:
|
volumes:
|
||||||
- ${REPO_PATH}:/repo:rw
|
- ${REPO_PATH}:/repo:rw
|
||||||
- ${RUNTIME_PATH}:/runtime:rw
|
- ${RUNTIME_PATH}:/runtime:rw
|
||||||
- ${RUNTIME_PATH}/scripts:/scripts:ro
|
- ${RUNTIME_PATH}/scripts:/scripts:ro
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH}/redis-data:/data
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|||||||
252
docs/lotto-premium-roadmap.md
Normal file
252
docs/lotto-premium-roadmap.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# 로또랩 프리미엄 서비스 고도화 로드맵
|
||||||
|
|
||||||
|
> 작성일: 2026-03-19
|
||||||
|
> 목표: 번호 생성 도구 → 데이터 기반 로또 전략 코치
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 현재 서비스 한계
|
||||||
|
|
||||||
|
현재 구조는 **"번호 생성 도구"** 수준으로 수익화에 한계가 있음.
|
||||||
|
|
||||||
|
| 문제 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 차별점 부재 | 무료 로또 번호 생성기와 구분되지 않음 |
|
||||||
|
| 신뢰 근거 부족 | 사용자가 결과를 믿을 데이터 시각화 없음 |
|
||||||
|
| 리텐션 약함 | 지속적으로 돌아올 이유가 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 포지셔닝 전환
|
||||||
|
|
||||||
|
> **"번호 생성"이 아니라 "데이터 기반 로또 전략 코치"**
|
||||||
|
|
||||||
|
사람들이 구독료를 지불하는 심리적 동기:
|
||||||
|
|
||||||
|
- **확신**: 내가 선택한 번호가 좋은 선택이라는 데이터 근거
|
||||||
|
- **FOMO**: 이번 주 리포트를 못 받으면 놓치는 느낌
|
||||||
|
- **소유감**: 내 데이터와 이력이 축적된다는 느낌
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 고도화 방향 (5가지)
|
||||||
|
|
||||||
|
### 3-1. 당첨 근접도 추적 — 신뢰 기반 구축
|
||||||
|
|
||||||
|
**목표**: 기존 채점 데이터(`check_results_for_draw`)를 신뢰 지표로 전환
|
||||||
|
|
||||||
|
**구현 내용**:
|
||||||
|
- 추천 번호의 회차별 일치 개수 통계 집계
|
||||||
|
- 전국 평균 대비 성과 비교 지표 노출
|
||||||
|
- 매주 "지난 주 내 번호 성과" 이메일/푸시 발송
|
||||||
|
|
||||||
|
**예시 UI 문구**:
|
||||||
|
```
|
||||||
|
"지난 52주간 우리 추천번호의 평균 일치 개수: 2.7개 (전국 평균 1.9개)"
|
||||||
|
"3개 일치율이 일반 무작위 대비 43% 높습니다"
|
||||||
|
```
|
||||||
|
|
||||||
|
**활용 데이터**: 기존 `recommendations` + `draws` 테이블 채점 결과
|
||||||
|
|
||||||
|
**우선순위**: ⭐⭐⭐ (데이터 이미 존재, 즉시 구현 가능)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3-2. 개인화 분석 리포트 — 프리미엄 핵심 기능
|
||||||
|
|
||||||
|
**목표**: 모든 사용자에게 동일한 번호 → 개인 패턴 기반 맞춤 추천
|
||||||
|
|
||||||
|
**구현 내용**:
|
||||||
|
- 사용자 번호 선택 이력 패턴 분석
|
||||||
|
- 홀짝 비율, 번호대 분포, 연속번호 포함률 등 개인 성향 분석
|
||||||
|
- 약점을 보완한 AI 보정 추천번호 생성
|
||||||
|
|
||||||
|
**예시 분석 항목**:
|
||||||
|
```
|
||||||
|
"당신은 홀수를 선호하는 경향 (67%)"
|
||||||
|
"당신이 자주 피하는 번호대: 30번대"
|
||||||
|
"당신 번호의 약점: 연속번호 포함률 낮음"
|
||||||
|
→ "이를 보완한 AI 보정 추천번호 제공"
|
||||||
|
```
|
||||||
|
|
||||||
|
**신규 테이블**: `user_preferences`
|
||||||
|
|
||||||
|
**우선순위**: ⭐⭐ (신규 테이블 및 분석 로직 필요)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3-3. 회차별 공략 리포트 — 킬러 콘텐츠
|
||||||
|
|
||||||
|
**목표**: 매주 추첨 전 발행하는 주간 분석 레포트 → 구독 유지 동기
|
||||||
|
|
||||||
|
**구현 내용**:
|
||||||
|
- 매주 자동 생성되는 회차별 공략 리포트
|
||||||
|
- 과출현/냉각 번호 분석
|
||||||
|
- 패턴 기반 번호군 추천
|
||||||
|
- AI 신뢰도 점수 표시
|
||||||
|
|
||||||
|
**예시 리포트 구조**:
|
||||||
|
```
|
||||||
|
[1180회 공략 리포트]
|
||||||
|
- 최근 10회 과출현 번호 제외 추천
|
||||||
|
- 이번 주 "냉각 구간" 번호 (오랫동안 미출현)
|
||||||
|
- 패턴 분석: 직전 3회 연속 출현한 번호군
|
||||||
|
- AI 신뢰도 점수: 87/100
|
||||||
|
```
|
||||||
|
|
||||||
|
**스케줄러**: 매주 토요일 추첨 전 자동 생성 (APScheduler)
|
||||||
|
|
||||||
|
**우선순위**: ⭐⭐⭐ (주간 구독 모델의 핵심 훅)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3-4. 번호 포트폴리오 관리 — 차별화 UX
|
||||||
|
|
||||||
|
**목표**: 로또를 투자처럼 관리하는 경험 제공
|
||||||
|
|
||||||
|
**구현 내용**:
|
||||||
|
- 세트 분류: 고위험/안정형/균형형
|
||||||
|
- 구매 금액 직접 입력 → 수익률 자동 계산
|
||||||
|
- 누적 투자 대비 당첨금 통계
|
||||||
|
|
||||||
|
**예시 화면**:
|
||||||
|
```
|
||||||
|
내 번호 포트폴리오
|
||||||
|
├── 고위험/고수익 세트 (출현 빈도 낮은 번호 조합)
|
||||||
|
├── 안정형 세트 (평균 출현 패턴)
|
||||||
|
└── 균형형 세트 (시뮬레이션 최적화)
|
||||||
|
|
||||||
|
이번 주 매입: 3세트 (₩3,000)
|
||||||
|
누적 투자: ₩240,000 / 누적 당첨: ₩45,000
|
||||||
|
수익률: -81.2% (전국 평균 대비 +12.1%)
|
||||||
|
```
|
||||||
|
|
||||||
|
**활용 데이터**: `best_picks`, `recommendations` 확장
|
||||||
|
|
||||||
|
**우선순위**: ⭐⭐ (UX 임팩트 큼, 중기 구현)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3-5. 커뮤니티 + 소셜 증거 — 바이럴 유도
|
||||||
|
|
||||||
|
**목표**: 사용자 참여 및 구전 마케팅
|
||||||
|
|
||||||
|
**구현 내용**:
|
||||||
|
- 이번 주 가장 많이 선택된 번호 TOP 10 공개
|
||||||
|
- "나와 같은 번호 선택한 회원 수" 표시
|
||||||
|
- AI 추천으로 X개 일치 달성한 회원 수 표시
|
||||||
|
|
||||||
|
**예시**:
|
||||||
|
```
|
||||||
|
"이번 주 가장 많이 선택된 번호 TOP 10"
|
||||||
|
"AI 추천 번호로 3개 일치 달성한 회원: 1,247명"
|
||||||
|
"나와 같은 번호를 선택한 회원: 34명"
|
||||||
|
```
|
||||||
|
|
||||||
|
**전략**: 무료 티어에 일부 공개 → 상세 분석은 유료 전환
|
||||||
|
|
||||||
|
**우선순위**: ⭐ (회원 시스템 구축 후 가능)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 구독 티어 설계
|
||||||
|
|
||||||
|
| 기능 | 무료 | 스탠다드 (₩2,900/월) | 프리미엄 (₩5,900/월) |
|
||||||
|
|------|:----:|:----:|:----:|
|
||||||
|
| 기본 추천 번호 | 1세트 | 5세트 | 무제한 |
|
||||||
|
| 통계 분석 | 기본 | 심화 | 전체 |
|
||||||
|
| 회차 공략 리포트 | - | 주간 요약 | 풀 리포트 |
|
||||||
|
| 개인 패턴 분석 | - | - | ✓ |
|
||||||
|
| 번호 포트폴리오 | - | ✓ | ✓ |
|
||||||
|
| 당첨 근접도 통계 | - | ✓ | ✓ |
|
||||||
|
| 당첨 알림 | - | 이메일 | 이메일 + 앱 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 기술 구현 로드맵
|
||||||
|
|
||||||
|
### Phase 1 — 즉시 가능 (데이터 이미 존재)
|
||||||
|
|
||||||
|
- [ ] 추천 이력 채점 통계 API (`GET /api/lotto/stats/performance`)
|
||||||
|
- [ ] 신뢰도 지표 UI (평균 일치 개수, 전국 평균 비교)
|
||||||
|
- [ ] 회차별 공략 리포트 API (`GET /api/lotto/report/{drw_no}`)
|
||||||
|
- [ ] 개인 추천 이력 성과 대시보드
|
||||||
|
|
||||||
|
### Phase 2 — 단기 (1-2주)
|
||||||
|
|
||||||
|
- [ ] `user_preferences` 테이블 설계 및 구현
|
||||||
|
- [ ] 개인 패턴 분석 API (`GET /api/lotto/analysis/personal`)
|
||||||
|
- [ ] 주간 리포트 자동 생성 스케줄러 (토요일 오전)
|
||||||
|
- [ ] 투자 추적 기능 (구매 금액 입력 → 수익률 계산)
|
||||||
|
- [ ] `purchase_history` 테이블 추가
|
||||||
|
|
||||||
|
### Phase 3 — 중기 (1개월)
|
||||||
|
|
||||||
|
- [ ] 회원 시스템 구축 (JWT 인증, SQLite `users` 테이블)
|
||||||
|
- [ ] 구독 플랜 관리 (`subscription_plans`, `user_subscriptions` 테이블)
|
||||||
|
- [ ] 결제 연동 (Toss Payments 또는 Stripe)
|
||||||
|
- [ ] 이메일 발송 자동화 (SendGrid)
|
||||||
|
- [ ] 소셜 증거 데이터 집계 API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. DB 스키마 확장 계획
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Phase 2
|
||||||
|
CREATE TABLE purchase_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
draw_no INTEGER NOT NULL,
|
||||||
|
amount INTEGER NOT NULL, -- 구매 금액 (원)
|
||||||
|
sets INTEGER NOT NULL DEFAULT 1, -- 구매 세트 수
|
||||||
|
prize INTEGER DEFAULT 0, -- 당첨금
|
||||||
|
note TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_preferences (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
odd_ratio REAL, -- 홀수 선호 비율
|
||||||
|
high_ratio REAL, -- 고번호(23+) 선호 비율
|
||||||
|
consecutive INTEGER, -- 연속번호 포함 선호 여부
|
||||||
|
excluded_numbers TEXT, -- JSON 배열, 기피 번호
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phase 3
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
plan TEXT DEFAULT 'free', -- free | standard | premium
|
||||||
|
plan_expires_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API 확장 계획
|
||||||
|
|
||||||
|
| Phase | 메서드 | 경로 | 설명 |
|
||||||
|
|-------|--------|------|------|
|
||||||
|
| 1 | GET | `/api/lotto/stats/performance` | 추천 성과 통계 (평균 일치 수 등) |
|
||||||
|
| 1 | GET | `/api/lotto/report/latest` | 최신 회차 공략 리포트 |
|
||||||
|
| 1 | GET | `/api/lotto/report/{drw_no}` | 특정 회차 공략 리포트 |
|
||||||
|
| 2 | GET | `/api/lotto/purchase` | 구매 이력 조회 |
|
||||||
|
| 2 | POST | `/api/lotto/purchase` | 구매 이력 추가 |
|
||||||
|
| 2 | GET | `/api/lotto/purchase/stats` | 투자 수익률 통계 |
|
||||||
|
| 2 | GET | `/api/lotto/analysis/personal` | 개인 패턴 분석 |
|
||||||
|
| 3 | POST | `/api/auth/register` | 회원가입 |
|
||||||
|
| 3 | POST | `/api/auth/login` | 로그인 |
|
||||||
|
| 3 | GET | `/api/subscription/plans` | 구독 플랜 목록 |
|
||||||
|
| 3 | POST | `/api/subscription/checkout` | 결제 시작 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- 현재 운영 중인 lotto API: `CLAUDE.md` → `lotto-lab API 목록` 섹션 참고
|
||||||
|
- 채점 로직: `backend/app/checker.py`
|
||||||
|
- 시뮬레이션 로직: `backend/app/recommender.py`
|
||||||
|
- DB 스키마: `backend/app/db.py` `init_db()`
|
||||||
672
docs/superpowers/plans/2026-04-07-pet-lab.md
Normal file
672
docs/superpowers/plans/2026-04-07-pet-lab.md
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
# Pet Lab Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Windows 데스크톱 펫 애플리케이션 — 화면 하단에 고정된 캐릭터가 마우스 시선을 추적하고 클릭/우클릭 상호작용을 지원한다.
|
||||||
|
|
||||||
|
**Architecture:** PyQt5 투명 프레임리스 윈도우에 캐릭터 이미지를 표시. QTimer 루프로 마우스 좌표를 폴링하여 이미지 기울기/반전으로 시선을 표현. 좌클릭(점프)/더블클릭(흔들기) 애니메이션과 우클릭 컨텍스트 메뉴 제공.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, PyQt5
|
||||||
|
|
||||||
|
**Project Path:** `C:\Users\jaeoh\Desktop\workspace\pet-lab`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 파일 | 역할 | 생성/수정 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `app/config.py` | 상수 정의 (크기, 위치, 애니메이션, 경로) | Create |
|
||||||
|
| `app/eye_tracker.py` | 마우스→기울기 각도/반전 계산 (순수 함수) | Create |
|
||||||
|
| `app/pet_widget.py` | 투명 윈도우 + 캐릭터 렌더링 + QTimer 루프 | Create |
|
||||||
|
| `app/interaction.py` | 클릭 애니메이션 + 우클릭 메뉴 | Create |
|
||||||
|
| `app/main.py` | 엔트리포인트 (QApplication 초기화) | Create |
|
||||||
|
| `assets/characters/박뚱냥.png` | 캐릭터 이미지 | Copy |
|
||||||
|
| `requirements.txt` | PyQt5 의존성 | Create |
|
||||||
|
| `tests/test_eye_tracker.py` | eye_tracker 단위 테스트 | Create |
|
||||||
|
| `tests/test_config.py` | config 상수 검증 테스트 | Create |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 프로젝트 초기화 + config.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\requirements.txt`
|
||||||
|
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\config.py`
|
||||||
|
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\tests\test_config.py`
|
||||||
|
- Copy: `Z:\homes\jaeoh\캐릭터\박뚱냥.jpg` → `C:\Users\jaeoh\Desktop\workspace\pet-lab\assets\characters\박뚱냥.png`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 프로젝트 디렉토리 생성 및 git 초기화**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p "C:\Users\jaeoh\Desktop\workspace\pet-lab"/{app,assets/characters,tests}
|
||||||
|
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||||
|
git init
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 캐릭터 이미지 복사**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp "Z:\homes\jaeoh\캐릭터\박뚱냥.jpg" "C:\Users\jaeoh\Desktop\workspace\pet-lab\assets\characters\박뚱냥.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
참고: 원본이 .jpg이지만 투명 배경이 있는 이미지이므로 그대로 사용. 파일명은 .png으로 저장하되, 실제 포맷이 JPG라면 PyQt5의 QPixmap이 자동 감지하므로 문제없음.
|
||||||
|
|
||||||
|
- [ ] **Step 3: requirements.txt 생성**
|
||||||
|
|
||||||
|
```
|
||||||
|
PyQt5>=5.15,<6.0
|
||||||
|
pytest>=7.0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 가상환경 생성 및 의존성 설치**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: config.py 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""pet-lab 설정 상수."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 캐릭터 크기 (높이 기준 px, 너비는 비율 유지)
|
||||||
|
SIZES = {"small": 100, "medium": 150, "large": 200}
|
||||||
|
DEFAULT_SIZE = "medium"
|
||||||
|
|
||||||
|
# 수평 위치 프리셋 (화면 너비 비율)
|
||||||
|
POSITIONS = {"left": 0.1, "center": 0.5, "right": 0.9}
|
||||||
|
DEFAULT_POSITION = "right"
|
||||||
|
|
||||||
|
# 시선 추적
|
||||||
|
TIMER_INTERVAL_MS = 30
|
||||||
|
MAX_TILT_ANGLE = 15.0
|
||||||
|
|
||||||
|
# 태스크바
|
||||||
|
TASKBAR_HEIGHT = 48
|
||||||
|
|
||||||
|
# 애니메이션
|
||||||
|
JUMP_HEIGHT = 30
|
||||||
|
JUMP_DURATION_MS = 300
|
||||||
|
SHAKE_OFFSET = 10
|
||||||
|
SHAKE_DURATION_MS = 400
|
||||||
|
|
||||||
|
# 에셋 경로
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
CHARACTER_DIR = os.path.join(BASE_DIR, "assets", "characters")
|
||||||
|
DEFAULT_CHARACTER = "박뚱냥.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: test_config.py 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""config 상수 검증."""
|
||||||
|
from app.config import SIZES, POSITIONS, DEFAULT_SIZE, DEFAULT_POSITION
|
||||||
|
from app.config import TIMER_INTERVAL_MS, MAX_TILT_ANGLE, CHARACTER_DIR
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def test_sizes_has_three_presets():
|
||||||
|
assert set(SIZES.keys()) == {"small", "medium", "large"}
|
||||||
|
assert all(isinstance(v, int) and v > 0 for v in SIZES.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_size_is_valid():
|
||||||
|
assert DEFAULT_SIZE in SIZES
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_has_three_presets():
|
||||||
|
assert set(POSITIONS.keys()) == {"left", "center", "right"}
|
||||||
|
assert all(0.0 < v < 1.0 for v in POSITIONS.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_position_is_valid():
|
||||||
|
assert DEFAULT_POSITION in POSITIONS
|
||||||
|
|
||||||
|
|
||||||
|
def test_timer_interval_is_reasonable():
|
||||||
|
assert 10 <= TIMER_INTERVAL_MS <= 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_tilt_angle_is_reasonable():
|
||||||
|
assert 5.0 <= MAX_TILT_ANGLE <= 45.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_character_dir_exists():
|
||||||
|
assert os.path.isdir(CHARACTER_DIR)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: 테스트 실행**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||||
|
python -m pytest tests/test_config.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 7 passed
|
||||||
|
|
||||||
|
- [ ] **Step 8: .gitignore 생성 및 커밋**
|
||||||
|
|
||||||
|
`.gitignore`:
|
||||||
|
```
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.spec
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: 프로젝트 초기화 — config, 캐릭터 에셋, 테스트"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: eye_tracker.py — 시선 계산 모듈
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\eye_tracker.py`
|
||||||
|
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\tests\test_eye_tracker.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: test_eye_tracker.py 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""eye_tracker 시선 계산 테스트."""
|
||||||
|
import math
|
||||||
|
from app.eye_tracker import compute_gaze
|
||||||
|
|
||||||
|
|
||||||
|
def test_mouse_right_of_character():
|
||||||
|
"""마우스가 캐릭터 오른쪽 → 양수 기울기, flip=False."""
|
||||||
|
angle, flip = compute_gaze(
|
||||||
|
char_center_x=500, char_center_y=900,
|
||||||
|
mouse_x=800, mouse_y=500,
|
||||||
|
max_angle=15.0,
|
||||||
|
)
|
||||||
|
assert 0 < angle <= 15.0
|
||||||
|
assert flip is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mouse_left_of_character():
|
||||||
|
"""마우스가 캐릭터 왼쪽 → 음수 기울기, flip=True."""
|
||||||
|
angle, flip = compute_gaze(
|
||||||
|
char_center_x=500, char_center_y=900,
|
||||||
|
mouse_x=200, mouse_y=500,
|
||||||
|
max_angle=15.0,
|
||||||
|
)
|
||||||
|
assert -15.0 <= angle < 0
|
||||||
|
assert flip is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_mouse_directly_above():
|
||||||
|
"""마우스가 캐릭터 바로 위 → 기울기 0, flip=False."""
|
||||||
|
angle, flip = compute_gaze(
|
||||||
|
char_center_x=500, char_center_y=900,
|
||||||
|
mouse_x=500, mouse_y=100,
|
||||||
|
max_angle=15.0,
|
||||||
|
)
|
||||||
|
assert angle == 0.0
|
||||||
|
assert flip is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mouse_at_character_position():
|
||||||
|
"""마우스가 캐릭터 위치와 동일 → 기울기 0, flip=False."""
|
||||||
|
angle, flip = compute_gaze(
|
||||||
|
char_center_x=500, char_center_y=500,
|
||||||
|
mouse_x=500, mouse_y=500,
|
||||||
|
max_angle=15.0,
|
||||||
|
)
|
||||||
|
assert angle == 0.0
|
||||||
|
assert flip is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_angle_clamped_to_max():
|
||||||
|
"""기울기가 max_angle을 초과하지 않아야 한다."""
|
||||||
|
angle, flip = compute_gaze(
|
||||||
|
char_center_x=500, char_center_y=500,
|
||||||
|
mouse_x=10000, mouse_y=500,
|
||||||
|
max_angle=15.0,
|
||||||
|
)
|
||||||
|
assert abs(angle) <= 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mouse_far_left():
|
||||||
|
"""마우스가 매우 왼쪽 → 기울기 -max_angle에 근접."""
|
||||||
|
angle, flip = compute_gaze(
|
||||||
|
char_center_x=500, char_center_y=500,
|
||||||
|
mouse_x=0, mouse_y=500,
|
||||||
|
max_angle=15.0,
|
||||||
|
)
|
||||||
|
assert angle < 0
|
||||||
|
assert flip is True
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실행 — 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_eye_tracker.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL with `ModuleNotFoundError: No module named 'app.eye_tracker'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: eye_tracker.py 구현**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""마우스 위치 기반 시선/기울기 계산 — 순수 함수 모듈."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def compute_gaze(
|
||||||
|
char_center_x: float,
|
||||||
|
char_center_y: float,
|
||||||
|
mouse_x: float,
|
||||||
|
mouse_y: float,
|
||||||
|
max_angle: float = 15.0,
|
||||||
|
) -> tuple[float, bool]:
|
||||||
|
"""캐릭터 중심과 마우스 위치로 기울기 각도와 좌우 반전 여부를 계산한다.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(tilt_angle, flip_horizontal)
|
||||||
|
- tilt_angle: -max_angle ~ +max_angle (도). 양수=우측 기울기, 음수=좌측 기울기.
|
||||||
|
- flip_horizontal: True면 이미지를 좌우 반전 (마우스가 캐릭터 왼쪽).
|
||||||
|
"""
|
||||||
|
dx = mouse_x - char_center_x
|
||||||
|
dy = mouse_y - char_center_y
|
||||||
|
|
||||||
|
if dx == 0 and dy == 0:
|
||||||
|
return 0.0, False
|
||||||
|
|
||||||
|
# dx 방향의 비율로 기울기 결정 (atan2로 각도 → 비율 변환)
|
||||||
|
angle_rad = math.atan2(abs(dx), max(abs(dy), 1))
|
||||||
|
ratio = angle_rad / (math.pi / 2) # 0~1 범위
|
||||||
|
tilt = ratio * max_angle
|
||||||
|
|
||||||
|
if dx < 0:
|
||||||
|
tilt = -tilt
|
||||||
|
|
||||||
|
# max_angle 클램핑
|
||||||
|
tilt = max(-max_angle, min(max_angle, tilt))
|
||||||
|
|
||||||
|
flip = dx < 0
|
||||||
|
|
||||||
|
return tilt, flip
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 실행 — 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/test_eye_tracker.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 6 passed
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/eye_tracker.py tests/test_eye_tracker.py
|
||||||
|
git commit -m "feat: eye_tracker — 마우스 시선 기울기 계산 모듈"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: pet_widget.py — 투명 윈도우 + 캐릭터 렌더링
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\pet_widget.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: pet_widget.py 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""투명 윈도우 위에 캐릭터를 렌더링하고 시선을 추적하는 메인 위젯."""
|
||||||
|
from PyQt5.QtWidgets import QWidget, QLabel, QApplication
|
||||||
|
from PyQt5.QtCore import Qt, QTimer, QPoint
|
||||||
|
from PyQt5.QtGui import QPixmap, QCursor, QTransform
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.config import (
|
||||||
|
SIZES, DEFAULT_SIZE, POSITIONS, DEFAULT_POSITION,
|
||||||
|
TIMER_INTERVAL_MS, MAX_TILT_ANGLE, TASKBAR_HEIGHT,
|
||||||
|
CHARACTER_DIR, DEFAULT_CHARACTER,
|
||||||
|
)
|
||||||
|
from app.eye_tracker import compute_gaze
|
||||||
|
|
||||||
|
|
||||||
|
class PetWidget(QWidget):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._size_key = DEFAULT_SIZE
|
||||||
|
self._position_key = DEFAULT_POSITION
|
||||||
|
self._always_on_top = True
|
||||||
|
self._last_mouse_pos = None
|
||||||
|
self._base_y = 0
|
||||||
|
|
||||||
|
self._init_window()
|
||||||
|
self._load_character()
|
||||||
|
self._position_on_screen()
|
||||||
|
self._start_tracking()
|
||||||
|
|
||||||
|
def _init_window(self):
|
||||||
|
flags = Qt.FramelessWindowHint | Qt.Tool
|
||||||
|
if self._always_on_top:
|
||||||
|
flags |= Qt.WindowStaysOnTopHint
|
||||||
|
self.setWindowFlags(flags)
|
||||||
|
self.setAttribute(Qt.WA_TranslucentBackground)
|
||||||
|
|
||||||
|
def _load_character(self):
|
||||||
|
path = os.path.join(CHARACTER_DIR, DEFAULT_CHARACTER)
|
||||||
|
self._original_pixmap = QPixmap(path)
|
||||||
|
self._label = QLabel(self)
|
||||||
|
self._apply_size()
|
||||||
|
|
||||||
|
def _apply_size(self):
|
||||||
|
height = SIZES[self._size_key]
|
||||||
|
scaled = self._original_pixmap.scaledToHeight(height, Qt.SmoothTransformation)
|
||||||
|
self._label.setPixmap(scaled)
|
||||||
|
self._label.setFixedSize(scaled.size())
|
||||||
|
self.setFixedSize(scaled.size())
|
||||||
|
|
||||||
|
def _position_on_screen(self):
|
||||||
|
screen = QApplication.primaryScreen().geometry()
|
||||||
|
char_height = SIZES[self._size_key]
|
||||||
|
self._base_y = screen.height() - TASKBAR_HEIGHT - char_height
|
||||||
|
x_ratio = POSITIONS[self._position_key]
|
||||||
|
x = int(screen.width() * x_ratio) - self.width() // 2
|
||||||
|
self.move(x, self._base_y)
|
||||||
|
|
||||||
|
def _start_tracking(self):
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.timeout.connect(self._update_gaze)
|
||||||
|
self._timer.start(TIMER_INTERVAL_MS)
|
||||||
|
|
||||||
|
def _update_gaze(self):
|
||||||
|
mouse_pos = QCursor.pos()
|
||||||
|
if self._last_mouse_pos == mouse_pos:
|
||||||
|
return
|
||||||
|
self._last_mouse_pos = mouse_pos
|
||||||
|
|
||||||
|
center = self.geometry().center()
|
||||||
|
tilt, flip = compute_gaze(
|
||||||
|
center.x(), center.y(),
|
||||||
|
mouse_pos.x(), mouse_pos.y(),
|
||||||
|
MAX_TILT_ANGLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
height = SIZES[self._size_key]
|
||||||
|
scaled = self._original_pixmap.scaledToHeight(height, Qt.SmoothTransformation)
|
||||||
|
|
||||||
|
transform = QTransform()
|
||||||
|
if flip:
|
||||||
|
transform.scale(-1, 1)
|
||||||
|
transform.rotate(tilt)
|
||||||
|
|
||||||
|
rotated = scaled.transformed(transform, Qt.SmoothTransformation)
|
||||||
|
self._label.setPixmap(rotated)
|
||||||
|
self._label.setFixedSize(rotated.size())
|
||||||
|
self.setFixedSize(rotated.size())
|
||||||
|
|
||||||
|
# ── 크기/위치 변경 (interaction.py에서 호출) ──
|
||||||
|
|
||||||
|
def set_size(self, size_key: str):
|
||||||
|
self._size_key = size_key
|
||||||
|
self._apply_size()
|
||||||
|
self._position_on_screen()
|
||||||
|
|
||||||
|
def set_position(self, position_key: str):
|
||||||
|
self._position_key = position_key
|
||||||
|
self._position_on_screen()
|
||||||
|
|
||||||
|
def toggle_always_on_top(self):
|
||||||
|
self._always_on_top = not self._always_on_top
|
||||||
|
flags = Qt.FramelessWindowHint | Qt.Tool
|
||||||
|
if self._always_on_top:
|
||||||
|
flags |= Qt.WindowStaysOnTopHint
|
||||||
|
self.setWindowFlags(flags)
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def always_on_top(self) -> bool:
|
||||||
|
return self._always_on_top
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_y(self) -> int:
|
||||||
|
return self._base_y
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 수동 테스트 — 투명 윈도우에 캐릭터 표시 확인**
|
||||||
|
|
||||||
|
임시 실행 스크립트:
|
||||||
|
```bash
|
||||||
|
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||||
|
python -c "
|
||||||
|
import sys
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
from app.pet_widget import PetWidget
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
pet = PetWidget()
|
||||||
|
pet.show()
|
||||||
|
sys.exit(app.exec_())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 화면 우하단에 박뚱냥이 표시되고, 마우스 이동 시 기울기/반전이 바뀜.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/pet_widget.py
|
||||||
|
git commit -m "feat: pet_widget — 투명 윈도우 + 시선 추적 렌더링"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: interaction.py — 클릭 반응 + 우클릭 메뉴
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\interaction.py`
|
||||||
|
- Modify: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\pet_widget.py` (마우스 이벤트 연결)
|
||||||
|
|
||||||
|
- [ ] **Step 1: interaction.py 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""클릭 애니메이션 + 우클릭 컨텍스트 메뉴."""
|
||||||
|
from PyQt5.QtWidgets import QMenu, QAction, QApplication
|
||||||
|
from PyQt5.QtCore import QPropertyAnimation, QEasingCurve, QPoint, QSequentialAnimationGroup
|
||||||
|
|
||||||
|
from app.config import (
|
||||||
|
JUMP_HEIGHT, JUMP_DURATION_MS,
|
||||||
|
SHAKE_OFFSET, SHAKE_DURATION_MS,
|
||||||
|
SIZES, POSITIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def play_jump(widget):
|
||||||
|
"""좌클릭 — 위로 점프 후 복귀."""
|
||||||
|
start = widget.pos()
|
||||||
|
top = QPoint(start.x(), start.y() - JUMP_HEIGHT)
|
||||||
|
|
||||||
|
anim = QPropertyAnimation(widget, b"pos")
|
||||||
|
anim.setDuration(JUMP_DURATION_MS)
|
||||||
|
anim.setStartValue(start)
|
||||||
|
anim.setKeyValueAt(0.4, top)
|
||||||
|
anim.setEndValue(start)
|
||||||
|
anim.setEasingCurve(QEasingCurve.OutBounce)
|
||||||
|
|
||||||
|
# prevent garbage collection
|
||||||
|
widget._current_anim = anim
|
||||||
|
anim.start()
|
||||||
|
|
||||||
|
|
||||||
|
def play_shake(widget):
|
||||||
|
"""더블클릭 — 좌우 흔들기."""
|
||||||
|
start = widget.pos()
|
||||||
|
left = QPoint(start.x() - SHAKE_OFFSET, start.y())
|
||||||
|
right = QPoint(start.x() + SHAKE_OFFSET, start.y())
|
||||||
|
|
||||||
|
group = QSequentialAnimationGroup(widget)
|
||||||
|
|
||||||
|
for end_pos in [left, right, left, right, start]:
|
||||||
|
anim = QPropertyAnimation(widget, b"pos")
|
||||||
|
anim.setDuration(SHAKE_DURATION_MS // 5)
|
||||||
|
anim.setEndValue(end_pos)
|
||||||
|
group.addAnimation(anim)
|
||||||
|
|
||||||
|
widget._current_anim = group
|
||||||
|
group.start()
|
||||||
|
|
||||||
|
|
||||||
|
def show_context_menu(widget, global_pos):
|
||||||
|
"""우클릭 — 컨텍스트 메뉴 표시."""
|
||||||
|
menu = QMenu()
|
||||||
|
|
||||||
|
# 위치 서브메뉴
|
||||||
|
pos_menu = menu.addMenu("위치")
|
||||||
|
for key, label in [("left", "좌"), ("center", "중앙"), ("right", "우")]:
|
||||||
|
action = pos_menu.addAction(label)
|
||||||
|
action.triggered.connect(lambda checked, k=key: widget.set_position(k))
|
||||||
|
|
||||||
|
# 크기 서브메뉴
|
||||||
|
size_menu = menu.addMenu("크기")
|
||||||
|
for key, label in [("small", "소 (100px)"), ("medium", "중 (150px)"), ("large", "대 (200px)")]:
|
||||||
|
action = size_menu.addAction(label)
|
||||||
|
action.triggered.connect(lambda checked, k=key: widget.set_size(k))
|
||||||
|
|
||||||
|
# 항상 위 토글
|
||||||
|
top_action = menu.addAction("항상 위" + (" ✓" if widget.always_on_top else ""))
|
||||||
|
top_action.triggered.connect(widget.toggle_always_on_top)
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
|
||||||
|
# 종료
|
||||||
|
quit_action = menu.addAction("종료")
|
||||||
|
quit_action.triggered.connect(QApplication.quit)
|
||||||
|
|
||||||
|
menu.exec_(global_pos)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: pet_widget.py에 마우스 이벤트 연결**
|
||||||
|
|
||||||
|
`pet_widget.py`의 `PetWidget` 클래스에 다음 메서드를 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ── 마우스 이벤트 (파일 하단, toggle_always_on_top 뒤에 추가) ──
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
if event.button() == Qt.RightButton:
|
||||||
|
from app.interaction import show_context_menu
|
||||||
|
show_context_menu(self, event.globalPos())
|
||||||
|
|
||||||
|
def mouseDoubleClickEvent(self, event):
|
||||||
|
if event.button() == Qt.LeftButton:
|
||||||
|
from app.interaction import play_shake
|
||||||
|
play_shake(self)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
if event.button() == Qt.LeftButton:
|
||||||
|
from app.interaction import play_jump
|
||||||
|
play_jump(self)
|
||||||
|
```
|
||||||
|
|
||||||
|
파일 상단 import에 추가 필요 없음 (lazy import 사용).
|
||||||
|
|
||||||
|
- [ ] **Step 3: 수동 테스트**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||||
|
python -c "
|
||||||
|
import sys
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
from app.pet_widget import PetWidget
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
pet = PetWidget()
|
||||||
|
pet.show()
|
||||||
|
sys.exit(app.exec_())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
테스트 항목:
|
||||||
|
- 좌클릭 → 점프 애니메이션
|
||||||
|
- 더블클릭 → 흔들기 애니메이션
|
||||||
|
- 우클릭 → 메뉴 표시 (위치/크기/항상위/종료)
|
||||||
|
- 메뉴에서 위치 변경 → 캐릭터 이동
|
||||||
|
- 메뉴에서 크기 변경 → 캐릭터 크기 변경
|
||||||
|
- 종료 → 앱 종료
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/interaction.py app/pet_widget.py
|
||||||
|
git commit -m "feat: interaction — 클릭 점프/흔들기 + 우클릭 메뉴"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: main.py — 엔트리포인트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:\Users\jaeoh\Desktop\workspace\pet-lab\app\main.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: main.py 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""pet-lab 엔트리포인트."""
|
||||||
|
import sys
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
from app.pet_widget import PetWidget
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setQuitOnLastWindowClosed(False)
|
||||||
|
|
||||||
|
pet = PetWidget()
|
||||||
|
pet.show()
|
||||||
|
|
||||||
|
sys.exit(app.exec_())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실행 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:\Users\jaeoh\Desktop\workspace\pet-lab"
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 박뚱냥이 화면 우하단에 표시되고, 시선 추적 + 클릭 반응 + 우클릭 메뉴 모두 동작.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/main.py
|
||||||
|
git commit -m "feat: main.py 엔트리포인트 — python -m app.main으로 실행"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- [x] 투명 윈도우 (Task 3: `FramelessWindowHint`, `WA_TranslucentBackground`, `Tool`)
|
||||||
|
- [x] 바닥 고정 (Task 3: `_position_on_screen`)
|
||||||
|
- [x] 시선 추적 (Task 2: `compute_gaze`, Task 3: `_update_gaze`)
|
||||||
|
- [x] 좌클릭 점프 (Task 4: `play_jump`)
|
||||||
|
- [x] 더블클릭 흔들기 (Task 4: `play_shake`)
|
||||||
|
- [x] 우클릭 메뉴 — 위치/크기/항상위/종료 (Task 4: `show_context_menu`)
|
||||||
|
- [x] config 상수 (Task 1: `config.py`)
|
||||||
|
- [x] 성능 최적화 — 마우스 변화 없으면 스킵 (Task 3: `_last_mouse_pos`)
|
||||||
|
|
||||||
|
**Placeholder scan:** 없음. 모든 step에 실제 코드 포함.
|
||||||
|
|
||||||
|
**Type consistency:** `compute_gaze` 시그니처 — Task 2 구현과 Task 3 호출 일치. `set_size`/`set_position` — Task 3 정의와 Task 4 호출 일치.
|
||||||
977
docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md
Normal file
977
docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md
Normal file
@@ -0,0 +1,977 @@
|
|||||||
|
# packs-lab 인프라 통합 + admin mint-token Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** packs-lab을 운영 가능 상태로 만든다 — admin upload 토큰 발급 endpoint + Supabase 스키마 + docker-compose/nginx/env 통합 + 통합 테스트 + 문서 갱신.
|
||||||
|
|
||||||
|
**Architecture:** 기존 코드(HMAC + DSM client + 4 라우트)는 그대로 유지하고, 신규 라우트 1개(`POST /api/packs/admin/mint-token`)를 routes.py에 추가한다. Supabase `pack_files` DDL 파일과 인프라(docker-compose 18950, nginx 5GB streaming, .env.example 6+1 환경변수)를 신설하고, 통합 테스트(routes + dsm_client mock)와 CLAUDE.md 5+1곳을 갱신한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12 / FastAPI / pytest + unittest.mock / Supabase(PostgreSQL) / Synology DSM 7.x API / nginx / Docker Compose
|
||||||
|
|
||||||
|
**스펙 참조:** `docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md`
|
||||||
|
|
||||||
|
**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-backend` (기존 web-backend repo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 테스트 인프라 — `tests/conftest.py`
|
||||||
|
|
||||||
|
기존 `tests/test_auth.py`는 `BACKEND_HMAC_SECRET=secret` 같은 fixture가 없어 환경변수 의존. 모든 테스트가 동일한 secret으로 동작하도록 autouse fixture를 conftest에 정리.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packs-lab/tests/conftest.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: conftest.py 생성**
|
||||||
|
|
||||||
|
`packs-lab/tests/conftest.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""packs-lab 테스트 공통 fixture."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _hmac_secret(monkeypatch):
|
||||||
|
"""모든 테스트에서 동일한 HMAC secret 사용. auth._SECRET 모듈 캐시까지 갱신."""
|
||||||
|
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
|
||||||
|
# auth.py 모듈은 import 시점에 _SECRET을 캐시하므로 monkeypatch로 함께 갱신
|
||||||
|
from app import auth
|
||||||
|
monkeypatch.setattr(auth, "_SECRET", "test-secret-do-not-use-in-prod")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 기존 test_auth.py 회귀 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab
|
||||||
|
python -m pytest tests/test_auth.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 기존 테스트 모두 PASS (conftest 영향 없거나 PASS 그대로 유지). 만약 secret 인코딩 차이로 실패 시 해당 테스트의 secret 사용 부분을 conftest 값과 일치시킨다.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/tests/conftest.py
|
||||||
|
git commit -m "test(packs-lab): conftest로 HMAC secret 통일"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: admin mint-token 라우트 (스키마 + 구현 + 테스트)
|
||||||
|
|
||||||
|
`POST /api/packs/admin/mint-token` 신규. Pydantic 스키마 추가 + 라우트 구현 + 통합 테스트.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packs-lab/app/models.py` (스키마 2개 추가)
|
||||||
|
- Modify: `packs-lab/app/routes.py` (import 보강 + 라우트 추가)
|
||||||
|
- Create: `packs-lab/tests/test_routes.py` (mint-token 관련 테스트만 우선)
|
||||||
|
|
||||||
|
- [ ] **Step 1: failing 테스트 작성**
|
||||||
|
|
||||||
|
`packs-lab/tests/test_routes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""packs-lab 라우트 통합 테스트.
|
||||||
|
|
||||||
|
DSM·Supabase는 mock. HMAC 검증·토큰 발급·검증은 실제 코드 사용.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
SECRET = "test-secret-do-not-use-in-prod"
|
||||||
|
|
||||||
|
|
||||||
|
def _hmac_headers(body_bytes: bytes) -> dict:
|
||||||
|
"""body에 대한 X-Timestamp + X-Signature 헤더 생성."""
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
sig = hmac.new(SECRET.encode(), ts.encode() + b"." + body_bytes, hashlib.sha256).hexdigest()
|
||||||
|
return {"X-Timestamp": ts, "X-Signature": sig}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_token_hmac_required():
|
||||||
|
"""HMAC 헤더 누락 → 401."""
|
||||||
|
client = TestClient(app)
|
||||||
|
body = {"tier": "pro", "label": "샘플", "filename": "x.zip", "size_bytes": 1024}
|
||||||
|
resp = client.post("/api/packs/admin/mint-token", json=body)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_token_returns_valid_token():
|
||||||
|
"""발급된 token이 verify_upload_token으로 통과해야 한다."""
|
||||||
|
from app.auth import verify_upload_token
|
||||||
|
|
||||||
|
body = {"tier": "pro", "label": "샘플", "filename": "test.zip", "size_bytes": 2048}
|
||||||
|
body_bytes = json.dumps(body).encode()
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "token" in data and "expires_at" in data and "jti" in data
|
||||||
|
|
||||||
|
payload = verify_upload_token(data["token"])
|
||||||
|
assert payload["tier"] == "pro"
|
||||||
|
assert payload["label"] == "샘플"
|
||||||
|
assert payload["filename"] == "test.zip"
|
||||||
|
assert payload["size_bytes"] == 2048
|
||||||
|
assert payload["jti"] == data["jti"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_token_invalid_filename():
|
||||||
|
"""허용 외 확장자 → 400."""
|
||||||
|
body = {"tier": "pro", "label": "샘플", "filename": "x.exe", "size_bytes": 1024}
|
||||||
|
body_bytes = json.dumps(body).encode()
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/test_routes.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 테스트 FAIL — `/api/packs/admin/mint-token` 라우트 없음 (404 또는 405).
|
||||||
|
|
||||||
|
- [ ] **Step 3: models.py에 스키마 추가**
|
||||||
|
|
||||||
|
`packs-lab/app/models.py` 끝부분에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MintTokenRequest(BaseModel):
|
||||||
|
"""Vercel → backend: admin upload 토큰 발급 요청."""
|
||||||
|
tier: PackTier
|
||||||
|
label: str = Field(..., max_length=200)
|
||||||
|
filename: str = Field(..., max_length=255)
|
||||||
|
size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024)
|
||||||
|
|
||||||
|
|
||||||
|
class MintTokenResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
expires_at: datetime
|
||||||
|
jti: str
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: routes.py에 mint-token 라우트 추가**
|
||||||
|
|
||||||
|
`packs-lab/app/routes.py` 상단 import 블록에 다음을 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time
|
||||||
|
from datetime import timezone
|
||||||
|
```
|
||||||
|
|
||||||
|
(이미 `import uuid`, `from datetime import datetime`은 있음)
|
||||||
|
|
||||||
|
`from .auth import` 라인을 다음과 같이 확장:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .auth import mint_upload_token, verify_request_hmac, verify_upload_token
|
||||||
|
```
|
||||||
|
|
||||||
|
`from .models import` 라인을 다음과 같이 확장:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .models import (
|
||||||
|
MintTokenRequest,
|
||||||
|
MintTokenResponse,
|
||||||
|
PackFileItem,
|
||||||
|
SignLinkRequest,
|
||||||
|
SignLinkResponse,
|
||||||
|
UploadResponse,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
상수 추가 (`MAX_BYTES` 다음 줄에):
|
||||||
|
|
||||||
|
```python
|
||||||
|
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
|
||||||
|
```
|
||||||
|
|
||||||
|
라우트 추가 (`sign_link` 함수 다음, `upload` 함수 앞):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.post("/admin/mint-token", response_model=MintTokenResponse)
|
||||||
|
async def mint_token(
|
||||||
|
request: Request,
|
||||||
|
x_timestamp: str = Header(""),
|
||||||
|
x_signature: str = Header(""),
|
||||||
|
):
|
||||||
|
body = await request.body()
|
||||||
|
verify_request_hmac(body, x_timestamp, x_signature)
|
||||||
|
payload = MintTokenRequest.model_validate_json(body)
|
||||||
|
_check_filename(payload.filename)
|
||||||
|
|
||||||
|
jti = str(uuid.uuid4())
|
||||||
|
expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
|
||||||
|
token = mint_upload_token({
|
||||||
|
"tier": payload.tier,
|
||||||
|
"label": payload.label,
|
||||||
|
"filename": payload.filename,
|
||||||
|
"size_bytes": payload.size_bytes,
|
||||||
|
"jti": jti,
|
||||||
|
"expires_at": expires_ts,
|
||||||
|
})
|
||||||
|
return MintTokenResponse(
|
||||||
|
token=token,
|
||||||
|
expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
|
||||||
|
jti=jti,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/test_routes.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 3 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/app/models.py packs-lab/app/routes.py packs-lab/tests/test_routes.py
|
||||||
|
git commit -m "feat(packs-lab): POST /api/packs/admin/mint-token 라우트 + 통합 테스트"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 기존 4 라우트 통합 테스트 (sign-link / upload / list / delete)
|
||||||
|
|
||||||
|
기존 라우트는 변경 없음. 테스트만 추가해 회귀 안전망 확보.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packs-lab/tests/test_routes.py` (테스트 8개 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: sign-link 테스트 추가**
|
||||||
|
|
||||||
|
`tests/test_routes.py` 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_sign_link_hmac_required():
|
||||||
|
"""HMAC 헤더 없으면 401."""
|
||||||
|
client = TestClient(app)
|
||||||
|
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/x.zip"}
|
||||||
|
resp = client.post("/api/packs/sign-link", json=body)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_link_outside_base_dir():
|
||||||
|
"""PACK_BASE_DIR 외부 경로 → 400."""
|
||||||
|
body = {"file_path": "/etc/passwd"}
|
||||||
|
body_bytes = json.dumps(body).encode()
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_link_calls_dsm():
|
||||||
|
"""DSM client 호출되고 응답 URL 반환."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/sample.zip"}
|
||||||
|
body_bytes = json.dumps(body).encode()
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
fake_url = "https://gahusb.synology.me:5001/sharing/abc123"
|
||||||
|
fake_expires = datetime(2026, 5, 5, 13, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
with patch("app.routes.create_share_link", new=AsyncMock(return_value=(fake_url, fake_expires))) as mock:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["url"] == fake_url
|
||||||
|
mock.assert_awaited_once()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: upload 테스트 추가**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _make_upload_token(tier="pro", label="샘플", filename="test.zip", size_bytes=1024, jti=None, ttl=1800):
|
||||||
|
"""테스트용 upload token 생성. mint_token endpoint 거치지 않고 직접."""
|
||||||
|
import uuid
|
||||||
|
from app.auth import mint_upload_token
|
||||||
|
return mint_upload_token({
|
||||||
|
"tier": tier,
|
||||||
|
"label": label,
|
||||||
|
"filename": filename,
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
"jti": jti or str(uuid.uuid4()),
|
||||||
|
"expires_at": int(time.time()) + ttl,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_token_required():
|
||||||
|
"""Authorization Bearer 누락 → 401."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/packs/upload", files={"file": ("x.zip", b"hello")})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_size_mismatch(tmp_path, monkeypatch):
|
||||||
|
"""토큰 size_bytes ≠ 실제 → 400 + 파일 정리됨."""
|
||||||
|
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||||
|
token = _make_upload_token(size_bytes=999) # 실제 5바이트지만 토큰엔 999
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/packs/upload",
|
||||||
|
files={"file": ("test.zip", b"hello")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "크기" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_jti_replay(tmp_path, monkeypatch):
|
||||||
|
"""같은 jti 토큰 두 번 → 두 번째 409."""
|
||||||
|
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||||
|
|
||||||
|
fake_supabase = MagicMock()
|
||||||
|
fake_supabase.table.return_value.insert.return_value.execute.return_value = MagicMock(
|
||||||
|
data=[{"uploaded_at": "2026-05-05T12:00:00+00:00"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
token = _make_upload_token(filename="replay.zip", size_bytes=5, jti="replay-jti-1")
|
||||||
|
|
||||||
|
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# 1차: 성공
|
||||||
|
resp1 = client.post(
|
||||||
|
"/api/packs/upload",
|
||||||
|
files={"file": ("replay.zip", b"hello")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
|
||||||
|
# 2차: 동일 토큰 재사용 — 두 번째 파일은 다른 이름으로 보내 파일명 충돌 회피
|
||||||
|
resp2 = client.post(
|
||||||
|
"/api/packs/upload",
|
||||||
|
files={"file": ("replay.zip", b"world")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 409
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: list / delete 테스트 추가**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_list_returns_active_only():
|
||||||
|
"""mock supabase가 deleted_at IS NULL 행만 반환하는지 (쿼리 빌더 호출 검증)."""
|
||||||
|
fake_rows = [
|
||||||
|
{
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"min_tier": "pro",
|
||||||
|
"label": "샘플",
|
||||||
|
"file_path": "/volume1/docker/webpage/media/packs/pro/a.zip",
|
||||||
|
"filename": "a.zip",
|
||||||
|
"size_bytes": 1024,
|
||||||
|
"sort_order": 0,
|
||||||
|
"uploaded_at": "2026-05-05T12:00:00+00:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
fake_supabase = MagicMock()
|
||||||
|
chain = fake_supabase.table.return_value.select.return_value
|
||||||
|
chain.is_.return_value.order.return_value.order.return_value.execute.return_value = MagicMock(data=fake_rows)
|
||||||
|
|
||||||
|
body_bytes = b""
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
|
||||||
|
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/packs/list", headers=headers)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
items = resp.json()
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["filename"] == "a.zip"
|
||||||
|
fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_soft_deletes():
|
||||||
|
"""DELETE 시 supabase update에 deleted_at ISO timestamp가 들어가야 한다."""
|
||||||
|
fake_supabase = MagicMock()
|
||||||
|
fake_supabase.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock(
|
||||||
|
data=[{"id": "abc"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
body_bytes = b""
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
|
||||||
|
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.delete("/api/packs/abc", headers=headers)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
update_call = fake_supabase.table.return_value.update.call_args
|
||||||
|
update_kwargs = update_call.args[0]
|
||||||
|
assert "deleted_at" in update_kwargs
|
||||||
|
# ISO 8601 timestamp 형식 검증 (예: 2026-05-05T12:00:00+00:00)
|
||||||
|
assert "T" in update_kwargs["deleted_at"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 실행**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/test_routes.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 11 passed (3 from Task 2 + 3 sign-link + 3 upload + 2 list/delete).
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/tests/test_routes.py
|
||||||
|
git commit -m "test(packs-lab): 기존 4 라우트 통합 테스트 (sign-link, upload, list, delete)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `tests/test_dsm_client.py` — DSM client mock 테스트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packs-lab/tests/test_dsm_client.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: DSM client 테스트 작성**
|
||||||
|
|
||||||
|
`packs-lab/tests/test_dsm_client.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""DSM 7.x API client 테스트 — httpx mock으로 외부 호출 차단."""
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.dsm_client import create_share_link, DSMError, _login, _logout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _dsm_env(monkeypatch):
|
||||||
|
monkeypatch.setenv("DSM_HOST", "https://test-nas:5001")
|
||||||
|
monkeypatch.setenv("DSM_USER", "test-user")
|
||||||
|
monkeypatch.setenv("DSM_PASS", "test-pass")
|
||||||
|
# 모듈 캐시도 갱신
|
||||||
|
from app import dsm_client
|
||||||
|
monkeypatch.setattr(dsm_client, "DSM_HOST", "https://test-nas:5001")
|
||||||
|
monkeypatch.setattr(dsm_client, "DSM_USER", "test-user")
|
||||||
|
monkeypatch.setattr(dsm_client, "DSM_PASS", "test-pass")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_response(json_data, status_code=200):
|
||||||
|
"""httpx.Response mock."""
|
||||||
|
mock = MagicMock(spec=httpx.Response)
|
||||||
|
mock.json.return_value = json_data
|
||||||
|
mock.status_code = status_code
|
||||||
|
mock.raise_for_status = MagicMock()
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_share_link_login_logout():
|
||||||
|
"""login → Sharing.create → logout 순서가 보장되어야 한다."""
|
||||||
|
call_order = []
|
||||||
|
|
||||||
|
async def fake_get(self, url, *, params=None, **kw):
|
||||||
|
api = (params or {}).get("api", "")
|
||||||
|
method = (params or {}).get("method", "")
|
||||||
|
call_order.append(f"{api}.{method}")
|
||||||
|
if api == "SYNO.API.Auth" and method == "login":
|
||||||
|
return _make_response({"success": True, "data": {"sid": "fake-sid"}})
|
||||||
|
if api == "SYNO.API.Auth" and method == "logout":
|
||||||
|
return _make_response({"success": True})
|
||||||
|
if api == "SYNO.FileStation.Sharing" and method == "create":
|
||||||
|
return _make_response({
|
||||||
|
"success": True,
|
||||||
|
"data": {"links": [{"url": "https://test-nas:5001/sharing/abc"}]},
|
||||||
|
})
|
||||||
|
return _make_response({"success": False, "error": "unexpected"})
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||||
|
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=3600))
|
||||||
|
|
||||||
|
assert url == "https://test-nas:5001/sharing/abc"
|
||||||
|
assert call_order == [
|
||||||
|
"SYNO.API.Auth.login",
|
||||||
|
"SYNO.FileStation.Sharing.create",
|
||||||
|
"SYNO.API.Auth.logout",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_share_link_returns_url_and_expiry():
|
||||||
|
"""응답 파싱 — links[0].url 사용."""
|
||||||
|
async def fake_get(self, url, *, params=None, **kw):
|
||||||
|
method = (params or {}).get("method", "")
|
||||||
|
if method == "login":
|
||||||
|
return _make_response({"success": True, "data": {"sid": "sid"}})
|
||||||
|
if method == "create":
|
||||||
|
return _make_response({
|
||||||
|
"success": True,
|
||||||
|
"data": {"links": [{"url": "https://nas/sharing/xyz"}]},
|
||||||
|
})
|
||||||
|
return _make_response({"success": True})
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||||
|
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=7200))
|
||||||
|
|
||||||
|
assert url == "https://nas/sharing/xyz"
|
||||||
|
assert expires_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsm_login_failure_raises():
|
||||||
|
"""login API success=False → DSMError."""
|
||||||
|
async def fake_get(self, url, *, params=None, **kw):
|
||||||
|
return _make_response({"success": False, "error": {"code": 400}})
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||||
|
with pytest.raises(DSMError, match="login 실패"):
|
||||||
|
asyncio.run(create_share_link("/volume1/test/file.zip"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsm_share_failure_logs_out():
|
||||||
|
"""Sharing.create 실패해도 logout 호출 (try/finally)."""
|
||||||
|
call_order = []
|
||||||
|
|
||||||
|
async def fake_get(self, url, *, params=None, **kw):
|
||||||
|
method = (params or {}).get("method", "")
|
||||||
|
call_order.append(method)
|
||||||
|
if method == "login":
|
||||||
|
return _make_response({"success": True, "data": {"sid": "sid"}})
|
||||||
|
if method == "create":
|
||||||
|
return _make_response({"success": False, "error": {"code": 401}})
|
||||||
|
if method == "logout":
|
||||||
|
return _make_response({"success": True})
|
||||||
|
return _make_response({"success": False})
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||||
|
with pytest.raises(DSMError, match="Sharing.create 실패"):
|
||||||
|
asyncio.run(create_share_link("/volume1/test/file.zip"))
|
||||||
|
|
||||||
|
assert "login" in call_order
|
||||||
|
assert "logout" in call_order, "logout이 호출되지 않음 (finally 누락 의심)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실행**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/test_dsm_client.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 4 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/tests/test_dsm_client.py
|
||||||
|
git commit -m "test(packs-lab): DSM client mock 테스트 (login/share/logout 순서)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: DELETE 라우트 docstring 수정
|
||||||
|
|
||||||
|
`routes.py` 모듈 docstring의 한 줄 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packs-lab/app/routes.py:1-7` (모듈 docstring)
|
||||||
|
|
||||||
|
- [ ] **Step 1: docstring 수정**
|
||||||
|
|
||||||
|
`packs-lab/app/routes.py` 첫 docstring을 다음으로 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""packs-lab API 엔드포인트.
|
||||||
|
|
||||||
|
- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
|
||||||
|
- POST /api/packs/admin/mint-token — Vercel HMAC 인증 → 일회성 upload 토큰
|
||||||
|
- POST /api/packs/upload — 일회성 토큰 인증 → multipart 저장 + supabase INSERT
|
||||||
|
- GET /api/packs/list — Vercel HMAC 인증 → pack_files 전체 조회
|
||||||
|
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
(변경: `정리` → `자동 만료`, mint-token 줄 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 회귀 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 테스트 그대로 통과 (15 passed).
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/app/routes.py
|
||||||
|
git commit -m "docs(packs-lab): routes 모듈 docstring 정리 (mint-token 추가, DSM 자동 만료 명시)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Supabase `pack_files` DDL
|
||||||
|
|
||||||
|
운영 적용 시 Supabase SQL editor에서 실행할 SQL 파일.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packs-lab/supabase/pack_files.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: SQL 파일 생성**
|
||||||
|
|
||||||
|
`packs-lab/supabase/pack_files.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
|
||||||
|
-- 운영 적용: Supabase Dashboard → SQL editor에서 실행
|
||||||
|
create table if not exists public.pack_files (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
min_tier text not null check (min_tier in ('starter','pro','master')),
|
||||||
|
label text not null,
|
||||||
|
file_path text not null unique,
|
||||||
|
filename text not null,
|
||||||
|
size_bytes bigint not null check (size_bytes > 0),
|
||||||
|
sort_order integer not null default 0,
|
||||||
|
uploaded_at timestamptz not null default now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- list 라우트 hot path: deleted_at IS NULL + tier/order 정렬
|
||||||
|
create index if not exists pack_files_active_idx
|
||||||
|
on public.pack_files (min_tier, sort_order)
|
||||||
|
where deleted_at is null;
|
||||||
|
|
||||||
|
-- soft-deleted 통계 / cleanup 잡 대비
|
||||||
|
create index if not exists pack_files_deleted_at_idx
|
||||||
|
on public.pack_files (deleted_at)
|
||||||
|
where deleted_at is not null;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/supabase/pack_files.sql
|
||||||
|
git commit -m "feat(packs-lab): Supabase pack_files DDL + 활성/삭제 인덱스"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 인프라 통합 — docker-compose / nginx / .env.example / deploy-nas.sh
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docker-compose.yml` (packs-lab 서비스 추가, env에 PACK_BASE_DIR/PACK_HOST_DIR 포함)
|
||||||
|
- Modify: `nginx/default.conf` (`/api/packs/` 라우팅)
|
||||||
|
- Modify: `.env.example` (DSM/HMAC/Supabase 6 + PACK 3 path)
|
||||||
|
- Modify: `scripts/deploy-nas.sh` (SERVICES 화이트리스트에 `packs-lab` 추가 — 누락 시 NAS 컨테이너 미등장)
|
||||||
|
|
||||||
|
- [ ] **Step 1: docker-compose.yml — packs-lab 서비스 추가**
|
||||||
|
|
||||||
|
`docker-compose.yml`에서 다른 lab 서비스(예: `realestate-lab`) 정의 다음에 추가:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
packs-lab:
|
||||||
|
build:
|
||||||
|
context: ./packs-lab
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: packs-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18950:8000"
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
DSM_HOST: ${DSM_HOST}
|
||||||
|
DSM_USER: ${DSM_USER}
|
||||||
|
DSM_PASS: ${DSM_PASS}
|
||||||
|
BACKEND_HMAC_SECRET: ${BACKEND_HMAC_SECRET}
|
||||||
|
SUPABASE_URL: ${SUPABASE_URL}
|
||||||
|
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
|
||||||
|
UPLOAD_TOKEN_TTL_SEC: ${UPLOAD_TOKEN_TTL_SEC:-1800}
|
||||||
|
volumes:
|
||||||
|
- ${PACK_DATA_PATH:-./data/packs}:/volume1/docker/webpage/media/packs
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: nginx/default.conf — /api/packs/ 라우팅**
|
||||||
|
|
||||||
|
기존 `location /api/agent-office/ { ... }` 다음(또는 다른 `/api/...` 라우트들 근처)에 추가:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/packs/ {
|
||||||
|
proxy_pass http://packs-lab:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# 5GB 멀티파트 업로드 대응
|
||||||
|
client_max_body_size 5G;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 1800s;
|
||||||
|
proxy_send_timeout 1800s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: .env.example — 6+1 환경변수 추가**
|
||||||
|
|
||||||
|
`.env.example` 끝에 추가:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
|
||||||
|
# Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||||
|
DSM_HOST=https://gahusb.synology.me:5001
|
||||||
|
DSM_USER=
|
||||||
|
DSM_PASS=
|
||||||
|
|
||||||
|
# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
|
||||||
|
BACKEND_HMAC_SECRET=
|
||||||
|
|
||||||
|
# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
|
||||||
|
SUPABASE_URL=https://<project>.supabase.co
|
||||||
|
SUPABASE_SERVICE_KEY=
|
||||||
|
|
||||||
|
# admin upload 토큰 TTL (초). default 1800 = 30분
|
||||||
|
UPLOAD_TOKEN_TTL_SEC=1800
|
||||||
|
|
||||||
|
# 로컬 개발: ./data/packs / NAS 운영: /volume1/docker/webpage/media/packs
|
||||||
|
PACK_DATA_PATH=./data/packs
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: docker compose config 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||||||
|
docker compose config 2>&1 | grep -A 10 "packs-lab:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: packs-lab 서비스 정의가 정상 출력 (port mapping, environment 변수, volumes 모두 보임). 환경변수가 비어있어도 docker compose config는 통과.
|
||||||
|
|
||||||
|
> ⚠️ Docker가 로컬에 설치되어 있어야 검증 가능. 실제 실행은 NAS에서. 로컬 docker가 없으면 step skip하고 nginx config 문법만 별도 검증.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker-compose.yml nginx/default.conf .env.example
|
||||||
|
git commit -m "chore(infra): packs-lab 서비스 통합 (compose 18950 + nginx 5GB streaming + env 7개)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: NAS 디렉토리 준비 가이드 + 문서 갱신
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/CLAUDE.md` (5곳 갱신)
|
||||||
|
- Modify: `workspace/CLAUDE.md` (1줄 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: web-backend/CLAUDE.md — 1.프로젝트 개요**
|
||||||
|
|
||||||
|
찾을 위치 (1.프로젝트 개요 섹션):
|
||||||
|
|
||||||
|
```
|
||||||
|
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
|
||||||
|
```
|
||||||
|
|
||||||
|
다음으로 수정:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||||
|
```
|
||||||
|
|
||||||
|
같은 섹션의 인프라 줄도:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: web-backend/CLAUDE.md — 4.Docker 서비스 표**
|
||||||
|
|
||||||
|
표 마지막에 신규 행 추가 (deployer 행 직전 또는 personal 행 다음 — 알파벳 순):
|
||||||
|
|
||||||
|
```
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: web-backend/CLAUDE.md — 5.Nginx 라우팅 표**
|
||||||
|
|
||||||
|
표 적절한 위치에 신규 행 추가:
|
||||||
|
|
||||||
|
```
|
||||||
|
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: web-backend/CLAUDE.md — 8.로컬 개발 표**
|
||||||
|
|
||||||
|
표 끝에 신규 행 추가:
|
||||||
|
|
||||||
|
```
|
||||||
|
| Packs Lab | http://localhost:18950 |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: web-backend/CLAUDE.md — 9.서비스별 packs-lab 신규 섹션**
|
||||||
|
|
||||||
|
`### deployer (deployer/)` 섹션 직전에 추가 (또는 personal 다음):
|
||||||
|
|
||||||
|
```
|
||||||
|
### packs-lab (packs-lab/)
|
||||||
|
- NAS 자료 다운로드 자동화 — Synology DSM 공유링크 발급 + 5GB 멀티파트 업로드 수신
|
||||||
|
- Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음)
|
||||||
|
- DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`)
|
||||||
|
- 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py`
|
||||||
|
- 운영 디렉토리: `/volume1/docker/webpage/media/packs/{starter,pro,master}/` (NAS PUID:PGID 권한 필요)
|
||||||
|
|
||||||
|
**환경변수**
|
||||||
|
- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||||
|
- `BACKEND_HMAC_SECRET`: Vercel SaaS와 양쪽 공유 시크릿 (HMAC SHA256)
|
||||||
|
- `SUPABASE_URL` / `SUPABASE_SERVICE_KEY`: Supabase pack_files 테이블 접근 (service_role, RLS 우회)
|
||||||
|
- `UPLOAD_TOKEN_TTL_SEC`: admin upload 토큰 TTL (기본 1800초 = 30분)
|
||||||
|
- `PACK_DATA_PATH`: 호스트 마운트 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`)
|
||||||
|
|
||||||
|
**HMAC 인증 패턴**
|
||||||
|
- Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret))
|
||||||
|
- Replay 방어: 타임스탬프 ±5분 윈도우
|
||||||
|
- admin browser → backend upload: `Authorization: Bearer <token>` (jti 단발성)
|
||||||
|
|
||||||
|
**packs-lab API 목록**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/packs/sign-link` | Vercel HMAC → DSM Sharing.create로 4시간 유효 다운로드 URL 발급 |
|
||||||
|
| POST | `/api/packs/admin/mint-token` | Vercel HMAC → 일회성 upload 토큰 발급 (기본 30분 TTL) |
|
||||||
|
| POST | `/api/packs/upload` | Bearer token → multipart 5GB 저장 + Supabase INSERT |
|
||||||
|
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
|
||||||
|
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: workspace/CLAUDE.md — 컨테이너 표 한 줄 추가**
|
||||||
|
|
||||||
|
`workspace/CLAUDE.md`의 "Docker 서비스 & 포트" 표에 추가:
|
||||||
|
|
||||||
|
```
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
|
||||||
|
```
|
||||||
|
|
||||||
|
(personal 행 다음 또는 적절한 위치)
|
||||||
|
|
||||||
|
- [ ] **Step 7: 커밋 (web-backend repo의 CLAUDE.md만)**
|
||||||
|
|
||||||
|
작업 디렉토리는 `C:\Users\jaeoh\Desktop\workspace\web-backend`. 그 안의 `CLAUDE.md`만 git 추적 대상.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs(claude): packs-lab 10번째 서비스로 등록 (포트/라우팅/API 표 + 신규 섹션)"
|
||||||
|
```
|
||||||
|
|
||||||
|
> ℹ️ `workspace/CLAUDE.md`(상위 디렉토리의 워크스페이스 메모)는 git repo가 아님. 텍스트 편집만 하고 commit 대상에서 제외.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 회귀 검증 + NAS 디렉토리 가이드
|
||||||
|
|
||||||
|
전체 테스트 + docker compose config + NAS 배포 전 가이드.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- (검증만)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 전체 pytest**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 테스트 통과 (test_auth + test_routes + test_dsm_client = 약 15+ tests).
|
||||||
|
|
||||||
|
- [ ] **Step 2: docker compose config 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||||||
|
docker compose config 2>&1 | tail -30
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: error 없이 packs-lab 포함된 전체 config 출력.
|
||||||
|
|
||||||
|
> ⚠️ Docker 미설치 시 skip. NAS에서 git push 후 webhook 배포 시점에 검증됨.
|
||||||
|
|
||||||
|
- [ ] **Step 3: NAS 배포 전 가이드 출력**
|
||||||
|
|
||||||
|
배포 전 NAS에서 SSH로 1회 실행할 명령들을 README 또는 NAS 배포 노트로 정리. 본 task에서는 명령만 제시 (실행은 사용자):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# NAS SSH로 접속 후
|
||||||
|
mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
|
||||||
|
chown -R PUID:PGID /volume1/docker/webpage/media/packs # PUID/PGID는 .env 값 사용
|
||||||
|
|
||||||
|
# .env에 신규 환경변수 추가 (DSM_*, BACKEND_HMAC_SECRET, SUPABASE_*, UPLOAD_TOKEN_TTL_SEC, PACK_DATA_PATH=/volume1/docker/webpage/media/packs)
|
||||||
|
|
||||||
|
# Supabase에서 packs-lab/supabase/pack_files.sql 실행
|
||||||
|
|
||||||
|
# git push 후 webhook이 자동 배포
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 최종 commit (검증 결과 빈 commit으로 마일스톤 표시 — 선택)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 만약 위 step에서 어떤 자동 수정이 있었으면 commit. 없으면 skip.
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
회귀 검증으로 변경 사항 없으면 별도 commit 없이 종료.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 기준
|
||||||
|
|
||||||
|
- 모든 task의 step 통과 (체크박스 모두 체크)
|
||||||
|
- `cd packs-lab && python -m pytest tests/ -v` — 통과 (test_auth + test_routes + test_dsm_client)
|
||||||
|
- `docker compose config` — packs-lab 포함된 전체 config 정상
|
||||||
|
- web-backend/CLAUDE.md 5곳 갱신 + workspace/CLAUDE.md 1줄
|
||||||
|
- Supabase DDL 파일 존재 (운영 적용은 사용자가 NAS에서 SQL editor로)
|
||||||
|
- NAS 디렉토리 준비 명령은 사용자가 SSH로 실행 (배포 전 1회)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배포
|
||||||
|
|
||||||
|
git push → Gitea webhook → deployer rsync → docker compose up -d --build (자동).
|
||||||
|
|
||||||
|
**배포 전 사용자 액션 (1회)**:
|
||||||
|
1. Supabase에서 `pack_files` 테이블 생성 (DDL 실행)
|
||||||
|
2. NAS SSH로 `/volume1/docker/webpage/media/packs/{starter,pro,master}` 디렉토리 생성 + 권한
|
||||||
|
3. NAS `.env`에 신규 7개 환경변수 입력 (DSM 인증, HMAC secret, Supabase 키 등)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 — 후속 별도 plan (스코프 외)
|
||||||
|
|
||||||
|
- Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase user 테이블
|
||||||
|
- DSM 공유 추적 (즉시 차단 필요 시)
|
||||||
|
- deleted_at + N일 후 실제 파일 삭제 cron
|
||||||
|
- multi-admin 토큰 발급 권한 분리
|
||||||
|
- resumable multipart 업로드 (5GB tus 등)
|
||||||
|
- pack_files sort_order 편집 endpoint
|
||||||
|
- 모니터링 (업로드 실패율, DSM API latency)
|
||||||
3325
docs/superpowers/plans/2026-05-07-music-youtube-pipeline.md
Normal file
3325
docs/superpowers/plans/2026-05-07-music-youtube-pipeline.md
Normal file
File diff suppressed because it is too large
Load Diff
2513
docs/superpowers/plans/2026-05-09-essential-mix-pipeline.md
Normal file
2513
docs/superpowers/plans/2026-05-09-essential-mix-pipeline.md
Normal file
File diff suppressed because it is too large
Load Diff
737
docs/superpowers/plans/2026-05-09-gpu-video-offload.md
Normal file
737
docs/superpowers/plans/2026-05-09-gpu-video-offload.md
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
# GPU 영상 인코딩 오프로드 — 구현 계획
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development.
|
||||||
|
|
||||||
|
**Goal:** NAS의 ffmpeg 영상 인코딩을 Windows PC(RTX 5070 Ti) NVENC로 오프로드.
|
||||||
|
|
||||||
|
**Architecture:** music-lab(NAS) → HTTP POST → music_ai(Windows, port 8765 `/encode_video`) → ffmpeg NVENC → SMB로 NAS에 직접 mp4 저장. Windows 서버 다운 시 NAS는 즉시 실패.
|
||||||
|
|
||||||
|
**Tech Stack:** httpx (NAS 측 HTTP 클라이언트), FastAPI (Windows 서버 endpoint), ffmpeg.exe with NVENC.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 경로 | 책임 |
|
||||||
|
|------|------|
|
||||||
|
| `music_ai/video_encoder.py` (new) | 경로 변환 + ffmpeg NVENC subprocess 호출 + 검증 |
|
||||||
|
| `music_ai/server.py` (modify) | `/encode_video` POST endpoint 등록, `/health`에 ffmpeg/nvenc 정보 추가 |
|
||||||
|
| `music_ai/.env.example` (modify) | NAS_VOLUME_PREFIX, WINDOWS_DRIVE_ROOT, FFMPEG_PATH 문서화 |
|
||||||
|
| `music_ai/tests/test_video_encoder.py` (new) | translate_path, encode endpoint 단위 테스트 |
|
||||||
|
| `music-lab/app/pipeline/video.py` (rewrite) | subprocess 제거, httpx로 Windows 서버 호출 |
|
||||||
|
| `music-lab/tests/test_video_thumb.py` (rewrite video tests) | respx mock 기반 |
|
||||||
|
| `web-backend/docker-compose.yml` (modify) | music-lab env 3개 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Windows `music_ai/video_encoder.py` + 테스트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `music_ai/video_encoder.py`
|
||||||
|
- Create: `music_ai/tests/test_video_encoder.py`
|
||||||
|
|
||||||
|
### Step 1: Write failing test
|
||||||
|
|
||||||
|
```python
|
||||||
|
# music_ai/tests/test_video_encoder.py
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from video_encoder import translate_path, encode_video, EncodeError
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(monkeypatch):
|
||||||
|
monkeypatch.setenv("NAS_VOLUME_PREFIX", "/volume1/")
|
||||||
|
monkeypatch.setenv("WINDOWS_DRIVE_ROOT", "Z:\\")
|
||||||
|
monkeypatch.setenv("FFMPEG_PATH", "C:\\ffmpeg\\bin\\ffmpeg.exe")
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_path_basic(env):
|
||||||
|
assert translate_path("/volume1/docker/webpage/data/x.jpg") == r"Z:\docker\webpage\data\x.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_path_nested(env):
|
||||||
|
assert translate_path("/volume1/docker/webpage/data/videos/3/cover.jpg") == r"Z:\docker\webpage\data\videos\3\cover.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_path_rejects_bad_prefix(env):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
translate_path("/etc/passwd")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_encode_video_success(mock_run, env, tmp_path):
|
||||||
|
# 입력 파일 fake
|
||||||
|
cover = tmp_path / "cover.jpg"
|
||||||
|
cover.write_bytes(b"\x00" * 100)
|
||||||
|
audio = tmp_path / "audio.mp3"
|
||||||
|
audio.write_bytes(b"\x00" * 100)
|
||||||
|
out = tmp_path / "video.mp4"
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
# ffmpeg 실행을 흉내내어 출력 파일을 만듦
|
||||||
|
out.write_bytes(b"\x00" * (2 * 1024 * 1024)) # 2MB
|
||||||
|
return MagicMock(returncode=0, stderr="")
|
||||||
|
mock_run.side_effect = fake_run
|
||||||
|
|
||||||
|
# translate_path를 mock해서 입력 경로를 직접 사용
|
||||||
|
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
|
||||||
|
result = encode_video(
|
||||||
|
cover_path_nas="/volume1/cover.jpg",
|
||||||
|
audio_path_nas="/volume1/audio.mp3",
|
||||||
|
output_path_nas="/volume1/video.mp4",
|
||||||
|
resolution="1920x1080",
|
||||||
|
duration_sec=120,
|
||||||
|
)
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["encoder"] == "h264_nvenc"
|
||||||
|
assert result["output_bytes"] > 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_encode_video_input_missing(mock_run, env, tmp_path):
|
||||||
|
with pytest.raises(EncodeError) as exc:
|
||||||
|
encode_video(
|
||||||
|
cover_path_nas="/volume1/missing.jpg",
|
||||||
|
audio_path_nas="/volume1/missing.mp3",
|
||||||
|
output_path_nas="/volume1/out.mp4",
|
||||||
|
resolution="1920x1080",
|
||||||
|
duration_sec=120,
|
||||||
|
)
|
||||||
|
assert "input_validation" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_encode_video_ffmpeg_failure(mock_run, env, tmp_path):
|
||||||
|
cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
|
||||||
|
audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stderr="invalid codec\n" * 50)
|
||||||
|
|
||||||
|
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
|
||||||
|
with pytest.raises(EncodeError) as exc:
|
||||||
|
encode_video(
|
||||||
|
cover_path_nas="/volume1/cover.jpg",
|
||||||
|
audio_path_nas="/volume1/audio.mp3",
|
||||||
|
output_path_nas="/volume1/out.mp4",
|
||||||
|
resolution="1920x1080",
|
||||||
|
duration_sec=120,
|
||||||
|
)
|
||||||
|
assert "ffmpeg" in str(exc.value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_encode_video_output_too_small(mock_run, env, tmp_path):
|
||||||
|
cover = tmp_path / "cover.jpg"; cover.write_bytes(b"\x00")
|
||||||
|
audio = tmp_path / "audio.mp3"; audio.write_bytes(b"\x00")
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
(tmp_path / "out.mp4").write_bytes(b"\x00" * 100) # 100 bytes — too small
|
||||||
|
return MagicMock(returncode=0, stderr="")
|
||||||
|
mock_run.side_effect = fake_run
|
||||||
|
|
||||||
|
with patch("video_encoder.translate_path", side_effect=lambda p: str(p).replace("/volume1/", str(tmp_path) + "/")):
|
||||||
|
with pytest.raises(EncodeError) as exc:
|
||||||
|
encode_video(
|
||||||
|
cover_path_nas="/volume1/cover.jpg",
|
||||||
|
audio_path_nas="/volume1/audio.mp3",
|
||||||
|
output_path_nas="/volume1/out.mp4",
|
||||||
|
resolution="1920x1080",
|
||||||
|
duration_sec=120,
|
||||||
|
)
|
||||||
|
assert "output_check" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolution_validation(env):
|
||||||
|
with pytest.raises(EncodeError) as exc:
|
||||||
|
encode_video(
|
||||||
|
cover_path_nas="/volume1/x.jpg",
|
||||||
|
audio_path_nas="/volume1/x.mp3",
|
||||||
|
output_path_nas="/volume1/out.mp4",
|
||||||
|
resolution="invalid",
|
||||||
|
duration_sec=120,
|
||||||
|
)
|
||||||
|
assert "resolution" in str(exc.value).lower()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Run test to verify it fails
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd music_ai && python -m pytest tests/test_video_encoder.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: ImportError on `video_encoder` module.
|
||||||
|
|
||||||
|
### Step 3: Implement `video_encoder.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""GPU(NVENC) 영상 인코더 — NAS music-lab에서 호출."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("music_ai.video_encoder")
|
||||||
|
|
||||||
|
NAS_VOLUME_PREFIX = os.getenv("NAS_VOLUME_PREFIX", "/volume1/")
|
||||||
|
WINDOWS_DRIVE_ROOT = os.getenv("WINDOWS_DRIVE_ROOT", "Z:\\")
|
||||||
|
FFMPEG_PATH = os.getenv("FFMPEG_PATH", "ffmpeg")
|
||||||
|
FFMPEG_TIMEOUT_S = 180
|
||||||
|
RESOLUTION_RE = re.compile(r"^\d{3,4}x\d{3,4}$")
|
||||||
|
MIN_OUTPUT_BYTES = 1024 * 1024 # 1MB
|
||||||
|
|
||||||
|
|
||||||
|
class EncodeError(Exception):
|
||||||
|
"""{stage: input_validation|path_translate|ffmpeg|output_check, message: ...}"""
|
||||||
|
def __init__(self, stage: str, message: str):
|
||||||
|
self.stage = stage
|
||||||
|
self.message = message
|
||||||
|
super().__init__(f"[{stage}] {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def translate_path(nas_path: str) -> str:
|
||||||
|
"""NAS 절대경로 → Windows SMB 경로."""
|
||||||
|
if not nas_path.startswith(NAS_VOLUME_PREFIX):
|
||||||
|
raise ValueError(f"NAS prefix 불일치: {nas_path}")
|
||||||
|
rel = nas_path[len(NAS_VOLUME_PREFIX):]
|
||||||
|
return WINDOWS_DRIVE_ROOT + rel.replace("/", "\\")
|
||||||
|
|
||||||
|
|
||||||
|
def encode_video(*, cover_path_nas: str, audio_path_nas: str,
|
||||||
|
output_path_nas: str, resolution: str,
|
||||||
|
duration_sec: int = 0, style: str = "visualizer") -> dict:
|
||||||
|
"""영상 인코딩 + Z:\\에 직접 저장."""
|
||||||
|
# 1) Resolution 검증
|
||||||
|
if not RESOLUTION_RE.match(resolution):
|
||||||
|
raise EncodeError("input_validation", f"invalid resolution: {resolution}")
|
||||||
|
w, h = resolution.split("x")
|
||||||
|
|
||||||
|
# 2) 경로 변환
|
||||||
|
try:
|
||||||
|
cover_win = translate_path(cover_path_nas)
|
||||||
|
audio_win = translate_path(audio_path_nas)
|
||||||
|
out_win = translate_path(output_path_nas)
|
||||||
|
except ValueError as e:
|
||||||
|
raise EncodeError("path_translate", str(e))
|
||||||
|
|
||||||
|
# 3) 입력 존재 확인
|
||||||
|
if not os.path.isfile(cover_win):
|
||||||
|
raise EncodeError("input_validation", f"cover not found: {cover_win}")
|
||||||
|
if not os.path.isfile(audio_win):
|
||||||
|
raise EncodeError("input_validation", f"audio not found: {audio_win}")
|
||||||
|
|
||||||
|
# 4) 출력 디렉토리 보장
|
||||||
|
os.makedirs(os.path.dirname(out_win), exist_ok=True)
|
||||||
|
|
||||||
|
# 5) ffmpeg 명령
|
||||||
|
cmd = [
|
||||||
|
FFMPEG_PATH, "-y",
|
||||||
|
"-hwaccel", "cuda",
|
||||||
|
"-loop", "1", "-i", cover_win,
|
||||||
|
"-i", audio_win,
|
||||||
|
"-filter_complex",
|
||||||
|
f"[0:v]scale={w}:{h},format=yuv420p[bg];"
|
||||||
|
f"[1:a]showwaves=s={w}x200:mode=cline:colors=0xFF4444@0.8[wave];"
|
||||||
|
f"[bg][wave]overlay=0:({h}-200)[out]",
|
||||||
|
"-map", "[out]", "-map", "1:a",
|
||||||
|
"-c:v", "h264_nvenc",
|
||||||
|
"-preset", "p4",
|
||||||
|
"-rc", "vbr",
|
||||||
|
"-cq", "23",
|
||||||
|
"-b:v", "0",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-c:a", "aac", "-b:a", "192k",
|
||||||
|
"-shortest", out_win,
|
||||||
|
]
|
||||||
|
logger.info("ffmpeg: %s", " ".join(cmd))
|
||||||
|
|
||||||
|
# 6) ffmpeg 실행
|
||||||
|
import time
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=FFMPEG_TIMEOUT_S)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise EncodeError("ffmpeg", f"timeout after {FFMPEG_TIMEOUT_S}s")
|
||||||
|
duration_ms = int((time.time() - t0) * 1000)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise EncodeError("ffmpeg", f"returncode={result.returncode}: {result.stderr[-800:]}")
|
||||||
|
|
||||||
|
# 7) 출력 검증
|
||||||
|
if not os.path.isfile(out_win):
|
||||||
|
raise EncodeError("output_check", "output file not created")
|
||||||
|
output_bytes = os.path.getsize(out_win)
|
||||||
|
if output_bytes < MIN_OUTPUT_BYTES:
|
||||||
|
raise EncodeError("output_check", f"output too small: {output_bytes} bytes")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"output_path_nas": output_path_nas,
|
||||||
|
"output_bytes": output_bytes,
|
||||||
|
"encoder": "h264_nvenc",
|
||||||
|
"preset": "p4",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_ffmpeg_nvenc() -> bool:
|
||||||
|
"""서버 시작 시 NVENC 가용성 확인."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[FFMPEG_PATH, "-encoders"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
return "h264_nvenc" in result.stdout
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd music_ai && python -m pytest tests/test_video_encoder.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 6 PASS
|
||||||
|
|
||||||
|
### Step 5: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/music_ai
|
||||||
|
git init 2>/dev/null || true # may not be a git repo, that's OK
|
||||||
|
# music_ai is local-only per CLAUDE.md, no remote push
|
||||||
|
```
|
||||||
|
|
||||||
|
(music_ai is local-only; just save the file. No git push needed.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Windows `music_ai/server.py` — `/encode_video` endpoint + 헬스 확장
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `music_ai/server.py`
|
||||||
|
- Modify: `music_ai/.env.example`
|
||||||
|
|
||||||
|
### Step 1: Read existing server.py to understand FastAPI pattern + existing /health
|
||||||
|
|
||||||
|
### Step 2: Add `/encode_video` endpoint
|
||||||
|
|
||||||
|
```python
|
||||||
|
# server.py — 추가
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import video_encoder
|
||||||
|
|
||||||
|
|
||||||
|
class EncodeVideoRequest(BaseModel):
|
||||||
|
cover_path_nas: str
|
||||||
|
audio_path_nas: str
|
||||||
|
output_path_nas: str
|
||||||
|
resolution: str = "1920x1080"
|
||||||
|
duration_sec: int = 0
|
||||||
|
style: str = "visualizer"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/encode_video")
|
||||||
|
def encode_video_endpoint(req: EncodeVideoRequest):
|
||||||
|
try:
|
||||||
|
result = video_encoder.encode_video(
|
||||||
|
cover_path_nas=req.cover_path_nas,
|
||||||
|
audio_path_nas=req.audio_path_nas,
|
||||||
|
output_path_nas=req.output_path_nas,
|
||||||
|
resolution=req.resolution,
|
||||||
|
duration_sec=req.duration_sec,
|
||||||
|
style=req.style,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except video_encoder.EncodeError as e:
|
||||||
|
# input_validation, path_translate → 400
|
||||||
|
# ffmpeg, output_check → 500
|
||||||
|
status_code = 400 if e.stage in ("input_validation", "path_translate") else 500
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status_code,
|
||||||
|
detail={"ok": False, "stage": e.stage, "error": e.message},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: 확장된 `/health`
|
||||||
|
|
||||||
|
기존 `/health` 응답에 추가:
|
||||||
|
```python
|
||||||
|
import torch # if existing health uses it
|
||||||
|
import video_encoder
|
||||||
|
|
||||||
|
# Module-level cache so health doesn't run ffmpeg every call
|
||||||
|
_FFMPEG_NVENC_CACHED = None
|
||||||
|
def _ffmpeg_nvenc_available():
|
||||||
|
global _FFMPEG_NVENC_CACHED
|
||||||
|
if _FFMPEG_NVENC_CACHED is None:
|
||||||
|
_FFMPEG_NVENC_CACHED = video_encoder.check_ffmpeg_nvenc()
|
||||||
|
return _FFMPEG_NVENC_CACHED
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, # 또는 기존 형식 유지
|
||||||
|
"musicgen_loaded": True, # 기존 그대로
|
||||||
|
"ffmpeg_path": video_encoder.FFMPEG_PATH,
|
||||||
|
"ffmpeg_nvenc": _ffmpeg_nvenc_available(),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(기존 `/health`의 정확한 형식은 코드 읽고 매칭. 위는 예시.)
|
||||||
|
|
||||||
|
### Step 4: `.env.example` 업데이트
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Existing
|
||||||
|
MODEL_NAME=facebook/musicgen-stereo-large
|
||||||
|
OUTPUT_DIR=output
|
||||||
|
SERVER_PORT=8765
|
||||||
|
|
||||||
|
# New for video encoder
|
||||||
|
NAS_VOLUME_PREFIX=/volume1/
|
||||||
|
WINDOWS_DRIVE_ROOT=Z:\
|
||||||
|
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: 수동 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd music_ai && start.bat # 또는 적절한 시작 명령
|
||||||
|
curl http://localhost:8765/health
|
||||||
|
# Expected: {..., "ffmpeg_nvenc": true}
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8765/encode_video -H "Content-Type: application/json" -d '{
|
||||||
|
"cover_path_nas": "/volume1/docker/webpage/data/videos/3/cover.jpg",
|
||||||
|
"audio_path_nas": "/volume1/docker/webpage/data/1c695df3-8a82-4c09-ba7b-82c07608ec5b.mp3",
|
||||||
|
"output_path_nas": "/volume1/docker/webpage/data/videos/test/video.mp4",
|
||||||
|
"resolution": "1920x1080",
|
||||||
|
"duration_sec": 176
|
||||||
|
}'
|
||||||
|
# Expected: 200 + duration_ms ~ 10-20초
|
||||||
|
```
|
||||||
|
|
||||||
|
(실제 파일 경로는 사용자 환경에 맞게 조정)
|
||||||
|
|
||||||
|
### Step 6: Commit (music_ai is local-only, no remote)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: NAS music-lab — `pipeline/video.py` 재작성 + 테스트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `music-lab/app/pipeline/video.py`
|
||||||
|
- Rewrite: `music-lab/tests/test_video_thumb.py` (video 부분만)
|
||||||
|
|
||||||
|
### Step 1: Replace failing tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
# music-lab/tests/test_video_thumb.py — video 관련 테스트 부분만 교체
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
import httpx
|
||||||
|
from httpx import Response
|
||||||
|
from app.pipeline import video, thumb, storage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def encoder_env(monkeypatch):
|
||||||
|
monkeypatch.setenv("WINDOWS_VIDEO_ENCODER_URL", "http://192.168.45.59:8765")
|
||||||
|
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_generate_video_calls_remote_encoder(encoder_env, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
|
||||||
|
respx.post("http://192.168.45.59:8765/encode_video").mock(
|
||||||
|
return_value=Response(200, json={
|
||||||
|
"ok": True, "duration_ms": 12000,
|
||||||
|
"output_path_nas": "/volume1/docker/webpage/data/videos/3/video.mp4",
|
||||||
|
"output_bytes": 28000000,
|
||||||
|
"encoder": "h264_nvenc", "preset": "p4",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
out = video.generate(
|
||||||
|
pipeline_id=3,
|
||||||
|
audio_path="/app/data/1c695df3.mp3",
|
||||||
|
cover_path="/app/data/videos/3/cover.jpg",
|
||||||
|
genre="lo-fi", duration_sec=120, resolution="1920x1080",
|
||||||
|
style="visualizer",
|
||||||
|
)
|
||||||
|
assert out["url"].endswith("/3/video.mp4")
|
||||||
|
assert out["used_fallback"] is False
|
||||||
|
assert out["encode_duration_ms"] == 12000
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_generate_video_raises_on_connection_error(encoder_env, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
|
||||||
|
respx.post("http://192.168.45.59:8765/encode_video").mock(
|
||||||
|
side_effect=httpx.ConnectError("Connection refused")
|
||||||
|
)
|
||||||
|
with pytest.raises(video.VideoGenerationError) as exc:
|
||||||
|
video.generate(
|
||||||
|
pipeline_id=4,
|
||||||
|
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/4/cover.jpg",
|
||||||
|
genre="lo-fi", duration_sec=120, resolution="1920x1080",
|
||||||
|
)
|
||||||
|
assert "연결 실패" in str(exc.value) or "Connection" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_generate_video_raises_on_500(encoder_env, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
|
||||||
|
respx.post("http://192.168.45.59:8765/encode_video").mock(
|
||||||
|
return_value=Response(500, json={"ok": False, "stage": "ffmpeg", "error": "bad codec"})
|
||||||
|
)
|
||||||
|
with pytest.raises(video.VideoGenerationError) as exc:
|
||||||
|
video.generate(
|
||||||
|
pipeline_id=5,
|
||||||
|
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/5/cover.jpg",
|
||||||
|
genre="lo-fi", duration_sec=120, resolution="1920x1080",
|
||||||
|
)
|
||||||
|
assert "Windows 인코더 오류" in str(exc.value)
|
||||||
|
assert "ffmpeg" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_video_no_url_configured(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(storage, "VIDEO_DATA_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr(video, "ENCODER_URL", "")
|
||||||
|
with pytest.raises(video.VideoGenerationError) as exc:
|
||||||
|
video.generate(
|
||||||
|
pipeline_id=6,
|
||||||
|
audio_path="/app/data/x.mp3", cover_path="/app/data/videos/6/cover.jpg",
|
||||||
|
genre="lo-fi", duration_sec=120, resolution="1920x1080",
|
||||||
|
)
|
||||||
|
assert "WINDOWS_VIDEO_ENCODER_URL" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_to_nas_videos_path(monkeypatch):
|
||||||
|
monkeypatch.setenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
||||||
|
monkeypatch.setenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
|
||||||
|
assert video._container_to_nas("/app/data/videos/3/cover.jpg") == "/volume1/docker/webpage/data/videos/3/cover.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_to_nas_music_path(monkeypatch):
|
||||||
|
monkeypatch.setenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
||||||
|
monkeypatch.setenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
|
||||||
|
assert video._container_to_nas("/app/data/abc.mp3") == "/volume1/docker/webpage/data/music/abc.mp3"
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 `test_generate_video_calls_ffmpeg`, `test_generate_video_failure_marks_failed` 삭제. thumb 관련 테스트는 그대로 유지.
|
||||||
|
|
||||||
|
### Step 2: Run, verify fail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd music-lab && python -m pytest tests/test_video_thumb.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: video 관련 테스트들이 실패 (또는 ImportError).
|
||||||
|
|
||||||
|
### Step 3: Rewrite `app/pipeline/video.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출.
|
||||||
|
|
||||||
|
Windows 서버 다운/실패 시 즉시 예외 (NAS 로컬 폴백 없음 — 의도적 결정).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from . import storage
|
||||||
|
|
||||||
|
logger = logging.getLogger("music-lab.video")
|
||||||
|
|
||||||
|
ENCODER_URL = os.getenv("WINDOWS_VIDEO_ENCODER_URL", "")
|
||||||
|
ENCODER_TIMEOUT_S = 200 # Windows 서버 ffmpeg 180s + 마진
|
||||||
|
|
||||||
|
# NAS 호스트 절대경로 prefix — docker bind mount의 host 측
|
||||||
|
NAS_VIDEOS_ROOT = os.getenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
||||||
|
NAS_MUSIC_ROOT = os.getenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
|
||||||
|
|
||||||
|
|
||||||
|
class VideoGenerationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def generate(*, pipeline_id: int, audio_path: str, cover_path: str,
|
||||||
|
genre: str, duration_sec: int, resolution: str = "1920x1080",
|
||||||
|
style: str = "visualizer") -> dict:
|
||||||
|
"""원격 Windows GPU 서버 호출. 다운/실패 시 즉시 예외."""
|
||||||
|
if not ENCODER_URL:
|
||||||
|
raise VideoGenerationError(
|
||||||
|
"WINDOWS_VIDEO_ENCODER_URL 미설정 — Windows 인코더 서버 주소 필요"
|
||||||
|
)
|
||||||
|
|
||||||
|
out_path = os.path.join(storage.pipeline_dir(pipeline_id), "video.mp4")
|
||||||
|
nas_audio = _container_to_nas(audio_path)
|
||||||
|
nas_cover = _container_to_nas(cover_path)
|
||||||
|
nas_output = _container_to_nas(out_path)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"cover_path_nas": nas_cover,
|
||||||
|
"audio_path_nas": nas_audio,
|
||||||
|
"output_path_nas": nas_output,
|
||||||
|
"resolution": resolution,
|
||||||
|
"duration_sec": duration_sec,
|
||||||
|
"style": style,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Windows 인코더 호출: pipeline=%d audio=%s", pipeline_id, audio_path)
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=ENCODER_TIMEOUT_S) as client:
|
||||||
|
resp = client.post(f"{ENCODER_URL}/encode_video", json=payload)
|
||||||
|
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout, httpx.NetworkError) as e:
|
||||||
|
raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}")
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
try:
|
||||||
|
detail = resp.json().get("detail", resp.json())
|
||||||
|
except Exception:
|
||||||
|
detail = {"error": resp.text[:300]}
|
||||||
|
stage = detail.get("stage", "?") if isinstance(detail, dict) else "?"
|
||||||
|
error = detail.get("error", str(detail)) if isinstance(detail, dict) else str(detail)
|
||||||
|
raise VideoGenerationError(
|
||||||
|
f"Windows 인코더 오류 ({resp.status_code}): {stage} — {error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
if not data.get("ok"):
|
||||||
|
raise VideoGenerationError(f"Windows 인코더 응답 ok=false: {data}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"url": storage.media_url(pipeline_id, "video.mp4"),
|
||||||
|
"used_fallback": False,
|
||||||
|
"duration_sec": duration_sec,
|
||||||
|
"encode_duration_ms": data.get("duration_ms"),
|
||||||
|
"encoder": data.get("encoder", "h264_nvenc"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _container_to_nas(container_path: str) -> str:
|
||||||
|
""" /app/data/videos/3/cover.jpg → /volume1/docker/webpage/data/videos/3/cover.jpg
|
||||||
|
/app/data/abc.mp3 → /volume1/docker/webpage/data/music/abc.mp3
|
||||||
|
"""
|
||||||
|
if container_path.startswith("/app/data/videos/"):
|
||||||
|
return container_path.replace("/app/data/videos/", NAS_VIDEOS_ROOT + "/", 1)
|
||||||
|
if container_path.startswith("/app/data/"):
|
||||||
|
rel = container_path[len("/app/data/"):]
|
||||||
|
return NAS_MUSIC_ROOT + "/" + rel
|
||||||
|
return container_path
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd music-lab && python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 73 PASS — 2 (제거) + 6 (신규) = 77? 아니면 73 그대로 — count 확인.
|
||||||
|
|
||||||
|
### Step 5: Commit + push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add music-lab/app/pipeline/video.py \
|
||||||
|
music-lab/tests/test_video_thumb.py
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(music-lab): 영상 인코딩을 Windows GPU 서버로 오프로드
|
||||||
|
|
||||||
|
- pipeline/video.py 재작성: subprocess.run 제거, httpx로 192.168.45.59:8765/encode_video 호출
|
||||||
|
- Windows 서버 다운 시 즉시 VideoGenerationError (NAS 로컬 폴백 X)
|
||||||
|
- /app/data/* → /volume1/docker/webpage/data/* 경로 변환 (_container_to_nas)
|
||||||
|
- 테스트는 respx mock 기반으로 교체 (6개 신규)"
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: docker-compose.yml env 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/docker-compose.yml`
|
||||||
|
|
||||||
|
### Step 1: music-lab 서비스 environment에 추가
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
music-lab:
|
||||||
|
environment:
|
||||||
|
# ... existing ...
|
||||||
|
- WINDOWS_VIDEO_ENCODER_URL=${WINDOWS_VIDEO_ENCODER_URL}
|
||||||
|
- NAS_VIDEOS_ROOT=${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
||||||
|
- NAS_MUSIC_ROOT=${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: docker-compose syntax 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend && python -c "import yaml; yaml.safe_load(open('docker-compose.yml'))" && echo OK
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Commit + push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add docker-compose.yml
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "chore(infra): GPU 인코더 env 추가 (WINDOWS_VIDEO_ENCODER_URL)"
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: 사용자 매뉴얼 단계 (사람이 직접)
|
||||||
|
|
||||||
|
후속 단계, 코드 작업 아님:
|
||||||
|
|
||||||
|
1. **Windows PC: ffmpeg 설치 + PATH 설정**
|
||||||
|
- https://www.gyan.dev/ffmpeg/builds/ → "release full" 다운로드
|
||||||
|
- `C:\ffmpeg\` 압축 해제 → `C:\ffmpeg\bin\ffmpeg.exe` 확인
|
||||||
|
- 시스템 PATH에 `C:\ffmpeg\bin` 추가
|
||||||
|
- 검증: `ffmpeg -version` + `ffmpeg -encoders | findstr h264_nvenc`
|
||||||
|
|
||||||
|
2. **Windows PC: `music_ai/.env` 추가**
|
||||||
|
```env
|
||||||
|
NAS_VOLUME_PREFIX=/volume1/
|
||||||
|
WINDOWS_DRIVE_ROOT=Z:\
|
||||||
|
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Windows PC: SMB 마운트 확인** — `Z:\docker\webpage\data\` 접근 가능
|
||||||
|
|
||||||
|
4. **Windows PC: `music_ai` 서버 재시작** — `start.bat`
|
||||||
|
|
||||||
|
5. **Windows PC 헬스 체크** — `curl http://localhost:8765/health` → `ffmpeg_nvenc: true` 확인
|
||||||
|
|
||||||
|
6. **NAS `.env`에 추가**
|
||||||
|
```env
|
||||||
|
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **NAS music-lab 재시작** — `docker compose up -d music-lab`
|
||||||
|
|
||||||
|
8. **E2E 테스트** — 진행 탭에서 새 파이프라인 시작, 영상 단계가 10–20초에 완료되는지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- §4 Windows endpoint → Task 1, 2 ✓
|
||||||
|
- §5 NAS video.py → Task 3 ✓
|
||||||
|
- §6 에러 처리 → Task 3 (httpx 예외 catch) ✓
|
||||||
|
- §7 헬스 모니터링 → Task 2 (`/health` 확장) ✓
|
||||||
|
- §8 테스트 → Task 1, 3 ✓
|
||||||
|
- §9 Windows 사전 준비 → Task 5 (사용자 수동) ✓
|
||||||
|
- §10 산출물 → 4 task로 모두 커버
|
||||||
|
|
||||||
|
**Placeholder scan:** 없음.
|
||||||
|
|
||||||
|
**Type consistency:**
|
||||||
|
- `EncodeError(stage, message)` Task 1 정의, Task 2에서 `e.stage`/`e.message` 사용 ✓
|
||||||
|
- `VideoGenerationError` Task 3에서 raise, 기존 orchestrator에서 catch ✓
|
||||||
|
- 응답 JSON 형식 spec §4-2와 일치 ✓
|
||||||
|
- 환경변수 이름 일관 (`NAS_VOLUME_PREFIX`, `WINDOWS_DRIVE_ROOT`, `FFMPEG_PATH`, `WINDOWS_VIDEO_ENCODER_URL`, `NAS_VIDEOS_ROOT`, `NAS_MUSIC_ROOT`)
|
||||||
|
|
||||||
|
---
|
||||||
815
docs/superpowers/plans/2026-05-10-batch-music-generation.md
Normal file
815
docs/superpowers/plans/2026-05-10-batch-music-generation.md
Normal file
@@ -0,0 +1,815 @@
|
|||||||
|
# Batch Music Generation — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** Use `superpowers:subagent-driven-development`. Steps use `- [ ]` checkboxes.
|
||||||
|
|
||||||
|
**Goal:** 장르 1개로 N(1-10) 트랙 Suno 자동 순차 생성 + 자동 컴파일 + 영상 파이프라인 자동 시작.
|
||||||
|
|
||||||
|
**Architecture:** music-lab 신규 `batch_generator` 모듈이 BackgroundTask로 N회 Suno 호출 → compile_job 자동 생성 → orchestrator.run_step("cover") 자동 호출.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-10-batch-music-generation-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 경로 | 책임 |
|
||||||
|
|------|------|
|
||||||
|
| `music-lab/app/db.py` (modify) | `music_batch_jobs` 테이블 + 5 헬퍼 |
|
||||||
|
| `music-lab/app/random_pools.py` (new) | 장르별 mood/instr/BPM/key/scale 랜덤 풀 + `randomize()` |
|
||||||
|
| `music-lab/app/batch_generator.py` (new) | `run_batch(batch_id)` 순차 오케스트레이션 |
|
||||||
|
| `music-lab/app/main.py` (modify) | 3개 endpoint (POST /generate-batch, GET /:id, GET 목록) |
|
||||||
|
| `web-ui/src/api.js` (modify) | 3개 헬퍼 |
|
||||||
|
| `web-ui/src/pages/music/components/BatchProgress.jsx` (new) | 진행 표시 컴포넌트 |
|
||||||
|
| `web-ui/src/pages/music/MusicStudio.jsx` (modify) | Create 탭에 배치 섹션 + 폴링 |
|
||||||
|
| `web-ui/src/pages/music/MusicStudio.css` (modify) | 배치 섹션 스타일 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: DB 테이블 + 헬퍼 + random_pools
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `music-lab/app/db.py`
|
||||||
|
- Create: `music-lab/app/random_pools.py`
|
||||||
|
- Test: `music-lab/tests/test_batch_db.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: random_pools.py 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""장르별 음악 파라미터 랜덤 풀."""
|
||||||
|
import random
|
||||||
|
|
||||||
|
POOLS = {
|
||||||
|
"lo-fi": {
|
||||||
|
"moods": ["chill", "relaxing", "dreamy", "melancholic", "mellow", "nostalgic", "peaceful"],
|
||||||
|
"instruments_pool": ["piano", "synth", "drums", "vinyl", "rhodes", "soft bass", "ambient pads"],
|
||||||
|
"instruments_count": (3, 4),
|
||||||
|
"bpm": (70, 90),
|
||||||
|
"keys": ["C", "D", "F", "G", "A"],
|
||||||
|
"scales": ["minor", "major"],
|
||||||
|
"prompt_modifiers": ["cozy bedroom vibes", "rainy night", "late night study", "cafe ambience"],
|
||||||
|
},
|
||||||
|
"phonk": {
|
||||||
|
"moods": ["dark", "aggressive", "moody", "intense", "hypnotic"],
|
||||||
|
"instruments_pool": ["808 bass", "hi-hat", "synth lead", "vocal chops", "bass drops", "trap drums"],
|
||||||
|
"instruments_count": (3, 4),
|
||||||
|
"bpm": (130, 160),
|
||||||
|
"keys": ["C", "D", "F", "G"],
|
||||||
|
"scales": ["minor"],
|
||||||
|
"prompt_modifiers": ["drift atmosphere", "dark neon", "midnight drive"],
|
||||||
|
},
|
||||||
|
"ambient": {
|
||||||
|
"moods": ["peaceful", "meditative", "ethereal", "spacious", "dreamy"],
|
||||||
|
"instruments_pool": ["pad synths", "atmospheric guitar", "soft strings", "field recordings", "drone bass"],
|
||||||
|
"instruments_count": (2, 3),
|
||||||
|
"bpm": (50, 75),
|
||||||
|
"keys": ["C", "D", "E", "G", "A"],
|
||||||
|
"scales": ["major", "minor"],
|
||||||
|
"prompt_modifiers": ["misty mountain morning", "deep space", "still water", "forest dawn"],
|
||||||
|
},
|
||||||
|
"pop": {
|
||||||
|
"moods": ["uplifting", "happy", "energetic", "romantic", "catchy"],
|
||||||
|
"instruments_pool": ["acoustic guitar", "piano", "drums", "bass", "synth", "vocals harmonies"],
|
||||||
|
"instruments_count": (3, 5),
|
||||||
|
"bpm": (95, 130),
|
||||||
|
"keys": ["C", "D", "E", "F", "G", "A"],
|
||||||
|
"scales": ["major"],
|
||||||
|
"prompt_modifiers": ["radio-ready", "summer vibe", "feel-good"],
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"moods": ["chill", "relaxing", "uplifting", "mellow"],
|
||||||
|
"instruments_pool": ["piano", "synth", "drums", "guitar", "bass", "strings"],
|
||||||
|
"instruments_count": (3, 4),
|
||||||
|
"bpm": (80, 110),
|
||||||
|
"keys": ["C", "D", "F", "G", "A"],
|
||||||
|
"scales": ["minor", "major"],
|
||||||
|
"prompt_modifiers": [""],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def randomize(genre: str, rng=None) -> dict:
|
||||||
|
rng = rng or random.Random()
|
||||||
|
pool = POOLS.get(genre.lower(), POOLS["default"])
|
||||||
|
n_instr = rng.randint(*pool["instruments_count"])
|
||||||
|
instruments = rng.sample(pool["instruments_pool"], min(n_instr, len(pool["instruments_pool"])))
|
||||||
|
return {
|
||||||
|
"moods": [rng.choice(pool["moods"])],
|
||||||
|
"instruments": instruments,
|
||||||
|
"bpm": rng.randint(*pool["bpm"]),
|
||||||
|
"key": rng.choice(pool["keys"]),
|
||||||
|
"scale": rng.choice(pool["scales"]),
|
||||||
|
"prompt_modifier": rng.choice(pool["prompt_modifiers"]),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: DB 테이블 + 헬퍼 추가** (db.py)
|
||||||
|
|
||||||
|
`init_db()`에 추가:
|
||||||
|
```python
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS music_batch_jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
genre TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL,
|
||||||
|
target_duration_sec INTEGER NOT NULL DEFAULT 180,
|
||||||
|
auto_pipeline INTEGER NOT NULL DEFAULT 1,
|
||||||
|
completed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
track_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
current_track_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
current_track_status TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
error TEXT,
|
||||||
|
compile_job_id INTEGER,
|
||||||
|
pipeline_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
```
|
||||||
|
|
||||||
|
`db.py` 끝에 헬퍼:
|
||||||
|
```python
|
||||||
|
_BATCH_ALLOWED_COLS = frozenset([
|
||||||
|
"completed", "track_ids_json", "current_track_index",
|
||||||
|
"current_track_status", "status", "error",
|
||||||
|
"compile_job_id", "pipeline_id",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def create_batch_job(genre: str, count: int, target_duration_sec: int = 180,
|
||||||
|
auto_pipeline: bool = True) -> int:
|
||||||
|
with _conn() as conn:
|
||||||
|
now = _now()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO music_batch_jobs
|
||||||
|
(genre, count, target_duration_sec, auto_pipeline,
|
||||||
|
status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'queued', ?, ?)
|
||||||
|
""", (genre, count, target_duration_sec, 1 if auto_pipeline else 0, now, now))
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_batch_job(batch_id: int) -> dict | None:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM music_batch_jobs WHERE id = ?", (batch_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
d["track_ids"] = json.loads(d.get("track_ids_json") or "[]")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def update_batch_job(batch_id: int, **fields) -> None:
|
||||||
|
unknown = set(fields) - _BATCH_ALLOWED_COLS
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"unknown batch job columns: {unknown}")
|
||||||
|
cols = ", ".join(f"{k} = ?" for k in fields)
|
||||||
|
vals = list(fields.values()) + [_now(), batch_id]
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE music_batch_jobs SET {cols}, updated_at = ? WHERE id = ?",
|
||||||
|
vals,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def append_batch_track(batch_id: int, track_id: int) -> None:
|
||||||
|
"""track_ids_json에 새 track_id 추가 + completed += 1 (atomic)."""
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT track_ids_json, completed FROM music_batch_jobs WHERE id = ?",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
ids = json.loads(row["track_ids_json"] or "[]")
|
||||||
|
ids.append(track_id)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE music_batch_jobs SET track_ids_json = ?, completed = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(json.dumps(ids), row["completed"] + 1, _now(), batch_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_batch_jobs(active_only: bool = False) -> list[dict]:
|
||||||
|
sql = "SELECT * FROM music_batch_jobs"
|
||||||
|
if active_only:
|
||||||
|
sql += " WHERE status NOT IN ('failed','cancelled','piped')"
|
||||||
|
sql += " ORDER BY created_at DESC"
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(sql).fetchall()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["track_ids"] = json.loads(d.get("track_ids_json") or "[]")
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_batch_db.py
|
||||||
|
import pytest
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fresh_db(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db"))
|
||||||
|
db.init_db()
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_batch_job(fresh_db):
|
||||||
|
bid = db.create_batch_job(genre="lo-fi", count=10)
|
||||||
|
j = db.get_batch_job(bid)
|
||||||
|
assert j["genre"] == "lo-fi"
|
||||||
|
assert j["count"] == 10
|
||||||
|
assert j["status"] == "queued"
|
||||||
|
assert j["track_ids"] == []
|
||||||
|
assert j["auto_pipeline"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_batch_job(fresh_db):
|
||||||
|
bid = db.create_batch_job(genre="phonk", count=5)
|
||||||
|
db.update_batch_job(bid, status="generating", current_track_index=2)
|
||||||
|
j = db.get_batch_job(bid)
|
||||||
|
assert j["status"] == "generating"
|
||||||
|
assert j["current_track_index"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_batch_rejects_unknown_col(fresh_db):
|
||||||
|
bid = db.create_batch_job(genre="lo-fi", count=1)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
db.update_batch_job(bid, evil_col="x")
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_batch_track(fresh_db):
|
||||||
|
bid = db.create_batch_job(genre="lo-fi", count=3)
|
||||||
|
db.append_batch_track(bid, 101)
|
||||||
|
db.append_batch_track(bid, 102)
|
||||||
|
j = db.get_batch_job(bid)
|
||||||
|
assert j["track_ids"] == [101, 102]
|
||||||
|
assert j["completed"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_batch_jobs_active_filter(fresh_db):
|
||||||
|
b1 = db.create_batch_job(genre="lo-fi", count=1)
|
||||||
|
b2 = db.create_batch_job(genre="phonk", count=1)
|
||||||
|
db.update_batch_job(b1, status="failed")
|
||||||
|
actives = db.list_batch_jobs(active_only=True)
|
||||||
|
assert all(j["status"] not in ("failed",) for j in actives)
|
||||||
|
assert any(j["id"] == b2 for j in actives)
|
||||||
|
assert not any(j["id"] == b1 for j in actives)
|
||||||
|
|
||||||
|
|
||||||
|
def test_random_pools_randomize():
|
||||||
|
from app.random_pools import randomize, POOLS
|
||||||
|
import random
|
||||||
|
rng = random.Random(42)
|
||||||
|
result = randomize("lo-fi", rng)
|
||||||
|
assert result["bpm"] in range(70, 91)
|
||||||
|
assert result["key"] in POOLS["lo-fi"]["keys"]
|
||||||
|
assert result["scale"] in POOLS["lo-fi"]["scales"]
|
||||||
|
assert len(result["moods"]) == 1
|
||||||
|
assert result["moods"][0] in POOLS["lo-fi"]["moods"]
|
||||||
|
assert 3 <= len(result["instruments"]) <= 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_random_pools_unknown_genre_uses_default():
|
||||||
|
from app.random_pools import randomize, POOLS
|
||||||
|
import random
|
||||||
|
result = randomize("nonexistent", random.Random(0))
|
||||||
|
assert result["bpm"] in range(80, 111) # default range
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd music-lab && python -m pytest tests/test_batch_db.py -v
|
||||||
|
```
|
||||||
|
Expected: 7 PASS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add music-lab/app/db.py music-lab/app/random_pools.py music-lab/tests/test_batch_db.py
|
||||||
|
git commit -m "feat(music-lab): music_batch_jobs 테이블 + 장르별 랜덤 풀"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: batch_generator + 3 엔드포인트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `music-lab/app/batch_generator.py`
|
||||||
|
- Modify: `music-lab/app/main.py`
|
||||||
|
- Test: `music-lab/tests/test_batch_endpoints.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: batch_generator.py 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""배치 음악 생성 + 자동 컴파일·영상 파이프라인."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .random_pools import randomize
|
||||||
|
|
||||||
|
logger = logging.getLogger("music-lab.batch")
|
||||||
|
|
||||||
|
POLL_INTERVAL_S = 5
|
||||||
|
TRACK_GEN_TIMEOUT_S = 240
|
||||||
|
|
||||||
|
|
||||||
|
async def run_batch(batch_id: int) -> None:
|
||||||
|
job = db.get_batch_job(batch_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
genre = job["genre"]
|
||||||
|
count = job["count"]
|
||||||
|
duration = job["target_duration_sec"]
|
||||||
|
auto_pipe = bool(job["auto_pipeline"])
|
||||||
|
|
||||||
|
db.update_batch_job(batch_id, status="generating")
|
||||||
|
|
||||||
|
track_ids: list[int] = []
|
||||||
|
for i in range(1, count + 1):
|
||||||
|
title = f"{genre.title()} Mix Track {i}"
|
||||||
|
params = randomize(genre)
|
||||||
|
db.update_batch_job(batch_id,
|
||||||
|
current_track_index=i,
|
||||||
|
current_track_status="generating")
|
||||||
|
|
||||||
|
track_id = await _generate_one_track(title=title, genre=genre,
|
||||||
|
duration_sec=duration,
|
||||||
|
params=params)
|
||||||
|
if track_id:
|
||||||
|
track_ids.append(track_id)
|
||||||
|
db.append_batch_track(batch_id, track_id)
|
||||||
|
db.update_batch_job(batch_id, current_track_status="succeeded")
|
||||||
|
else:
|
||||||
|
db.update_batch_job(batch_id, current_track_status="failed")
|
||||||
|
logger.warning("배치 %d 트랙 %d 실패 — 계속 진행", batch_id, i)
|
||||||
|
|
||||||
|
if not track_ids:
|
||||||
|
db.update_batch_job(batch_id, status="failed",
|
||||||
|
error="모든 트랙 생성 실패")
|
||||||
|
return
|
||||||
|
|
||||||
|
db.update_batch_job(batch_id, status="generated")
|
||||||
|
|
||||||
|
if not auto_pipe:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 자동 컴파일
|
||||||
|
db.update_batch_job(batch_id, status="compiling")
|
||||||
|
try:
|
||||||
|
compile_id = db.create_compile_job(
|
||||||
|
title=f"{genre.title()} Mix",
|
||||||
|
track_ids=track_ids,
|
||||||
|
crossfade_sec=3,
|
||||||
|
)
|
||||||
|
db.update_batch_job(batch_id, compile_job_id=compile_id)
|
||||||
|
except Exception as e:
|
||||||
|
db.update_batch_job(batch_id, status="failed", error=f"compile create: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
from . import compiler
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(compiler.run, compile_id)
|
||||||
|
except Exception as e:
|
||||||
|
db.update_batch_job(batch_id, status="failed", error=f"compile run: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
job_after = db.get_compile_job(compile_id)
|
||||||
|
if not job_after or job_after.get("status") not in ("done", "succeeded"):
|
||||||
|
db.update_batch_job(
|
||||||
|
batch_id, status="failed",
|
||||||
|
error=f"compile not done (status={job_after.get('status') if job_after else 'unknown'})"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 자동 영상 파이프라인
|
||||||
|
pipeline_id = db.create_pipeline(compile_job_id=compile_id)
|
||||||
|
db.update_batch_job(batch_id, pipeline_id=pipeline_id, status="piped")
|
||||||
|
|
||||||
|
from .pipeline import orchestrator
|
||||||
|
await orchestrator.run_step(pipeline_id, "cover")
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_one_track(*, title: str, genre: str, duration_sec: int,
|
||||||
|
params: dict) -> int | None:
|
||||||
|
"""기존 Suno generate 호출 + 완료까지 polling. 성공 시 새 track id 반환."""
|
||||||
|
from .suno_provider import run_suno_generation
|
||||||
|
from .db import create_task, get_task
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
suno_params = {
|
||||||
|
"title": title,
|
||||||
|
"genre": genre,
|
||||||
|
"moods": params["moods"],
|
||||||
|
"instruments": params["instruments"],
|
||||||
|
"duration_sec": duration_sec,
|
||||||
|
"bpm": params["bpm"],
|
||||||
|
"key": params["key"],
|
||||||
|
"scale": params["scale"],
|
||||||
|
"prompt": params.get("prompt_modifier", ""),
|
||||||
|
}
|
||||||
|
create_task(task_id, suno_params, provider="suno")
|
||||||
|
|
||||||
|
# Suno background task 직접 호출 (BackgroundTasks 미사용 — 우리가 await)
|
||||||
|
asyncio.create_task(asyncio.to_thread(run_suno_generation, task_id, suno_params))
|
||||||
|
|
||||||
|
# Polling
|
||||||
|
waited = 0
|
||||||
|
while waited < TRACK_GEN_TIMEOUT_S:
|
||||||
|
await asyncio.sleep(POLL_INTERVAL_S)
|
||||||
|
waited += POLL_INTERVAL_S
|
||||||
|
task = get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
continue
|
||||||
|
if task.get("status") == "succeeded":
|
||||||
|
tr = task.get("track")
|
||||||
|
return tr.get("id") if tr else None
|
||||||
|
if task.get("status") == "failed":
|
||||||
|
return None
|
||||||
|
return None # timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: This assumes existing `db.create_task`, `db.get_task`, `suno_provider.run_suno_generation` are reusable. Read existing code to confirm function signatures, adjust if needed (especially `task["track"]["id"]` vs other format).
|
||||||
|
|
||||||
|
- [ ] **Step 2: main.py에 3 endpoint 추가**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.batch_generator import run_batch as _run_batch
|
||||||
|
|
||||||
|
|
||||||
|
class BatchGenerateRequest(BaseModel):
|
||||||
|
genre: str
|
||||||
|
count: int = 10
|
||||||
|
target_duration_sec: int = 180
|
||||||
|
auto_pipeline: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/music/generate-batch", status_code=201)
|
||||||
|
async def generate_batch(req: BatchGenerateRequest, bg: BackgroundTasks):
|
||||||
|
if not (1 <= req.count <= 10):
|
||||||
|
raise HTTPException(400, "count는 1-10 사이")
|
||||||
|
if not (60 <= req.target_duration_sec <= 300):
|
||||||
|
raise HTTPException(400, "target_duration_sec는 60-300 사이")
|
||||||
|
if not req.genre:
|
||||||
|
raise HTTPException(400, "genre 필수")
|
||||||
|
if not SUNO_API_KEY:
|
||||||
|
raise HTTPException(400, "SUNO_API_KEY 미설정")
|
||||||
|
|
||||||
|
batch_id = _db_module.create_batch_job(
|
||||||
|
genre=req.genre, count=req.count,
|
||||||
|
target_duration_sec=req.target_duration_sec,
|
||||||
|
auto_pipeline=req.auto_pipeline,
|
||||||
|
)
|
||||||
|
bg.add_task(_run_batch, batch_id)
|
||||||
|
return _db_module.get_batch_job(batch_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/music/generate-batch/{batch_id}")
|
||||||
|
def get_batch(batch_id: int):
|
||||||
|
j = _db_module.get_batch_job(batch_id)
|
||||||
|
if not j:
|
||||||
|
raise HTTPException(404)
|
||||||
|
# tracks 메타 LEFT JOIN (id, title, audio_url)
|
||||||
|
if j["track_ids"]:
|
||||||
|
ids_csv = ",".join(str(i) for i in j["track_ids"])
|
||||||
|
# 간단한 in-Python 매핑 (sqlite IN (...))
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(_db_module.DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT id, title, audio_url, duration_sec FROM music_library WHERE id IN ({ids_csv})"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
j["tracks"] = [dict(r) for r in rows]
|
||||||
|
else:
|
||||||
|
j["tracks"] = []
|
||||||
|
return j
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/music/generate-batch")
|
||||||
|
def list_batches(status: str = "all"):
|
||||||
|
return {"batches": _db_module.list_batch_jobs(active_only=(status == "active"))}
|
||||||
|
```
|
||||||
|
|
||||||
|
(SUNO_API_KEY는 main.py에 이미 import돼있다고 가정. 없으면 `_db_module` 패턴처럼 처리.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 테스트 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_batch_endpoints.py
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.main import app
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db"))
|
||||||
|
db.init_db()
|
||||||
|
monkeypatch.setenv("SUNO_API_KEY", "test")
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_batch_201(client):
|
||||||
|
with patch("app.main._run_batch", new=AsyncMock()):
|
||||||
|
r = client.post("/api/music/generate-batch",
|
||||||
|
json={"genre": "lo-fi", "count": 3})
|
||||||
|
assert r.status_code == 201
|
||||||
|
body = r.json()
|
||||||
|
assert body["genre"] == "lo-fi"
|
||||||
|
assert body["count"] == 3
|
||||||
|
assert body["status"] == "queued"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_batch_rejects_count_too_high(client):
|
||||||
|
r = client.post("/api/music/generate-batch",
|
||||||
|
json={"genre": "lo-fi", "count": 11})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_batch_rejects_count_zero(client):
|
||||||
|
r = client.post("/api/music/generate-batch",
|
||||||
|
json={"genre": "lo-fi", "count": 0})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_batch_rejects_no_genre(client):
|
||||||
|
r = client.post("/api/music/generate-batch", json={"count": 3})
|
||||||
|
# Pydantic missing 필드 → 422 (FastAPI default validation)
|
||||||
|
assert r.status_code in (400, 422)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_batch_returns_tracks(client):
|
||||||
|
bid = db.create_batch_job(genre="lo-fi", count=2)
|
||||||
|
db.append_batch_track(bid, 999) # phantom track id (not in library)
|
||||||
|
r = client.get(f"/api/music/generate-batch/{bid}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["track_ids"] == [999]
|
||||||
|
# tracks 배열은 비어있음 (해당 track 미존재)
|
||||||
|
assert body["tracks"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_batches(client):
|
||||||
|
db.create_batch_job(genre="lo-fi", count=1)
|
||||||
|
db.create_batch_job(genre="phonk", count=2)
|
||||||
|
r = client.get("/api/music/generate-batch")
|
||||||
|
assert len(r.json()["batches"]) == 2
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run + commit + push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd music-lab && python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
Expected: 모두 PASS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-backend add music-lab/app/batch_generator.py \
|
||||||
|
music-lab/app/main.py \
|
||||||
|
music-lab/tests/test_batch_endpoints.py
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-backend commit -m "feat(music-lab): 배치 음악 생성 endpoint + orchestrator"
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-backend push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend Create 탭 배치 섹션
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-ui/src/api.js`
|
||||||
|
- Create: `web-ui/src/pages/music/components/BatchProgress.jsx`
|
||||||
|
- Modify: `web-ui/src/pages/music/MusicStudio.jsx`
|
||||||
|
- Modify: `web-ui/src/pages/music/MusicStudio.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: api.js 헬퍼**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// === Batch generation ===
|
||||||
|
export const startBatchGen = (payload) => apiPost('/api/music/generate-batch', payload);
|
||||||
|
export const getBatchJob = (id) => apiGet(`/api/music/generate-batch/${id}`);
|
||||||
|
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: BatchProgress.jsx 신규**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
queued: '대기 중', generating: '음악 생성 중', generated: '음악 완료, 컴파일 대기',
|
||||||
|
compiling: '컴파일 중', piped: '영상 파이프라인 시작됨',
|
||||||
|
failed: '실패', cancelled: '취소',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BatchProgress({ batch }) {
|
||||||
|
if (!batch) return null;
|
||||||
|
const trackList = Array.from({ length: batch.count }, (_, i) => i + 1);
|
||||||
|
return (
|
||||||
|
<div className="ms-batch-progress">
|
||||||
|
<div className="ms-batch-header">
|
||||||
|
배치 #{batch.id} — {batch.genre} ·{' '}
|
||||||
|
{batch.completed}/{batch.count} 완료 ·{' '}
|
||||||
|
<strong>{STATUS_LABELS[batch.status] || batch.status}</strong>
|
||||||
|
</div>
|
||||||
|
{batch.error && <div className="ms-error">에러: {batch.error}</div>}
|
||||||
|
<ol className="ms-batch-tracks">
|
||||||
|
{trackList.map(n => {
|
||||||
|
const completed = n <= batch.completed;
|
||||||
|
const current = n === batch.current_track_index && batch.status === 'generating';
|
||||||
|
const tr = (batch.tracks || [])[n - 1];
|
||||||
|
return (
|
||||||
|
<li key={n} className={completed ? 'done' : current ? 'current' : 'pending'}>
|
||||||
|
{completed ? '✓' : current ? '⏳' : '○'}
|
||||||
|
{' '}Track {n}: {tr?.title || (current ? '생성 중...' : '대기')}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
{batch.compile_job_id && (
|
||||||
|
<div className="ms-batch-link">📀 컴파일 #{batch.compile_job_id}</div>
|
||||||
|
)}
|
||||||
|
{batch.pipeline_id && (
|
||||||
|
<div className="ms-batch-link">
|
||||||
|
🎬 영상 파이프라인 #{batch.pipeline_id} —
|
||||||
|
{' '}<em>YouTube 탭 → 진행 탭에서 확인</em>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: MusicStudio.jsx Create 탭에 배치 섹션 추가**
|
||||||
|
|
||||||
|
Create 탭 jsx 영역 (handleGenerate 근처) 위 또는 옆에:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import BatchProgress from './components/BatchProgress';
|
||||||
|
import { startBatchGen, getBatchJob } from '../../api';
|
||||||
|
|
||||||
|
// 컴포넌트 내부 state:
|
||||||
|
const [batchOpen, setBatchOpen] = useState(false);
|
||||||
|
const [batchGenre, setBatchGenre] = useState('lo-fi');
|
||||||
|
const [batchCount, setBatchCount] = useState(10);
|
||||||
|
const [batchDuration, setBatchDuration] = useState(180);
|
||||||
|
const [batchAutoPipe, setBatchAutoPipe] = useState(true);
|
||||||
|
const [currentBatch, setCurrentBatch] = useState(null);
|
||||||
|
const [batchPolling, setBatchPolling] = useState(false);
|
||||||
|
const batchPollRef = useRef(null);
|
||||||
|
|
||||||
|
const startBatch = async () => {
|
||||||
|
try {
|
||||||
|
const res = await startBatchGen({
|
||||||
|
genre: batchGenre,
|
||||||
|
count: batchCount,
|
||||||
|
target_duration_sec: batchDuration,
|
||||||
|
auto_pipeline: batchAutoPipe,
|
||||||
|
});
|
||||||
|
setCurrentBatch(res);
|
||||||
|
setBatchPolling(true);
|
||||||
|
} catch (e) {
|
||||||
|
alert(`배치 시작 실패: ${e.message || e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!batchPolling || !currentBatch?.id) return;
|
||||||
|
const tick = async () => {
|
||||||
|
const j = await getBatchJob(currentBatch.id).catch(() => null);
|
||||||
|
if (j) {
|
||||||
|
setCurrentBatch(j);
|
||||||
|
if (['piped', 'failed', 'cancelled'].includes(j.status)) {
|
||||||
|
setBatchPolling(false);
|
||||||
|
if (j.pipeline_id) loadLibrary?.(); // refresh library to show new tracks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
batchPollRef.current = setInterval(tick, 5000);
|
||||||
|
return () => clearInterval(batchPollRef.current);
|
||||||
|
}, [batchPolling, currentBatch?.id]);
|
||||||
|
|
||||||
|
// ... Create 탭 jsx 안:
|
||||||
|
<details className="ms-batch-section" open={batchOpen} onToggle={(e) => setBatchOpen(e.target.open)}>
|
||||||
|
<summary>🎲 배치 생성 (장르 → 1-10트랙 + 자동 영상)</summary>
|
||||||
|
<div className="ms-batch-form">
|
||||||
|
<label>장르
|
||||||
|
<select value={batchGenre} onChange={e => setBatchGenre(e.target.value)}>
|
||||||
|
<option value="lo-fi">Lo-Fi</option>
|
||||||
|
<option value="phonk">Phonk</option>
|
||||||
|
<option value="ambient">Ambient</option>
|
||||||
|
<option value="pop">Pop</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>트랙 수: {batchCount}
|
||||||
|
<input type="range" min={1} max={10} value={batchCount}
|
||||||
|
onChange={e => setBatchCount(parseInt(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label>트랙당 길이: {batchDuration}초
|
||||||
|
<input type="range" min={60} max={300} step={10} value={batchDuration}
|
||||||
|
onChange={e => setBatchDuration(parseInt(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label className="ms-batch-checkbox">
|
||||||
|
<input type="checkbox" checked={batchAutoPipe}
|
||||||
|
onChange={e => setBatchAutoPipe(e.target.checked)} />
|
||||||
|
모든 트랙 생성 후 자동 영상 파이프라인 시작
|
||||||
|
</label>
|
||||||
|
<p className="ms-batch-estimate">
|
||||||
|
예상: 약 {Math.ceil(batchCount * 1.5)}-{batchCount * 2}분 ·
|
||||||
|
비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<button className="button primary" onClick={startBatch}
|
||||||
|
disabled={batchPolling}>
|
||||||
|
🎵 배치 생성 시작
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{currentBatch && <BatchProgress batch={currentBatch} />}
|
||||||
|
</details>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: CSS 추가**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* === Batch generation section === */
|
||||||
|
.ms-batch-section { margin: 16px 0; padding: 12px; background: rgba(0,0,0,.2);
|
||||||
|
border: 1px solid var(--ms-line, #2a2a3a); border-radius: 12px; }
|
||||||
|
.ms-batch-section summary { cursor: pointer; font-weight: bold; color: var(--ms-text, #f0f0f5); }
|
||||||
|
.ms-batch-form { display: flex; flex-direction: column; gap: 10px; padding: 12px 0; }
|
||||||
|
.ms-batch-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
||||||
|
.ms-batch-form input[type="range"] { width: 100%; }
|
||||||
|
.ms-batch-checkbox { flex-direction: row !important; align-items: center; gap: 8px; }
|
||||||
|
.ms-batch-checkbox input { width: auto; }
|
||||||
|
.ms-batch-estimate { font-size: 12px; color: var(--ms-muted, #a0a0b0); }
|
||||||
|
|
||||||
|
.ms-batch-progress { margin-top: 12px; padding: 12px; background: rgba(0,0,0,.3);
|
||||||
|
border-radius: 8px; }
|
||||||
|
.ms-batch-header { font-size: 13px; margin-bottom: 8px; }
|
||||||
|
.ms-batch-tracks { padding-left: 24px; font-size: 12px; }
|
||||||
|
.ms-batch-tracks li { margin: 2px 0; }
|
||||||
|
.ms-batch-tracks li.done { color: #86efac; }
|
||||||
|
.ms-batch-tracks li.current { color: var(--ms-accent, #38bdf8); font-weight: bold; }
|
||||||
|
.ms-batch-tracks li.pending { color: var(--ms-muted, #a0a0b0); }
|
||||||
|
.ms-batch-link { margin-top: 8px; font-size: 12px; color: var(--ms-muted, #a0a0b0); }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build + verify + commit + push + deploy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web-ui && npm run build 2>&1 | tail -5
|
||||||
|
npx eslint src/pages/music/components/BatchProgress.jsx src/pages/music/MusicStudio.jsx 2>&1 | tail
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/api.js \
|
||||||
|
src/pages/music/components/BatchProgress.jsx \
|
||||||
|
src/pages/music/MusicStudio.jsx \
|
||||||
|
src/pages/music/MusicStudio.css
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(web-ui): Create 탭 배치 생성 섹션 + BatchProgress"
|
||||||
|
git -C C:/Users/jaeoh/Desktop/workspace/web-ui push origin main
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run release:nas
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 수동 E2E 검증
|
||||||
|
|
||||||
|
- [ ] Create 탭 → 배치 생성 섹션 펼침 → genre=lo-fi, count=3 (테스트로 적게), duration=120s, auto_pipeline=on → "배치 생성 시작"
|
||||||
|
- [ ] BatchProgress에 Track 1/2/3 진행 표시 확인
|
||||||
|
- [ ] ~5분 후 Library에 3개 트랙 추가됨
|
||||||
|
- [ ] 컴파일 진행 확인 (status: compiling)
|
||||||
|
- [ ] 영상 파이프라인 시작됨 (status: piped) + pipeline_id 표시
|
||||||
|
- [ ] YouTube 탭 → 진행 탭에 새 카드, cover 단계 진행 중
|
||||||
|
- [ ] 텔레그램에 cover 알림 도착
|
||||||
|
- [ ] 일반 흐름대로 5단계 승인 후 발행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- §3 사용자 흐름 → Task 3 (UI 섹션)
|
||||||
|
- §4 데이터 모델 → Task 1
|
||||||
|
- §5 백엔드 (random_pools, batch_generator) → Task 1, 2
|
||||||
|
- §6 API → Task 2
|
||||||
|
- §7 프론트엔드 → Task 3
|
||||||
|
- §8 에러 처리 → Task 2 (validation, try/except)
|
||||||
|
- §9 테스트 → Task 1, 2
|
||||||
|
- §10 산출물 → 4 task로 모두 커버
|
||||||
|
|
||||||
|
**Placeholder scan:** 없음.
|
||||||
|
|
||||||
|
**Type consistency:**
|
||||||
|
- `batch_id` int, `count` int, `genre` str — 일관
|
||||||
|
- `track_ids` list[int]
|
||||||
|
- `status` 7값 (queued/generating/generated/compiling/piped/failed/cancelled) 일관
|
||||||
|
|
||||||
|
**스펙 보정:** §5-2 batch_generator의 `_generate_one_track`에서 `db.create_task`/`db.get_task` 사용 — 이 함수들이 기존 db.py에 있는지 미확인. Task 2 Step 1 NOTE에 명시함.
|
||||||
2753
docs/superpowers/plans/2026-05-15-insta-agent-implementation.md
Normal file
2753
docs/superpowers/plans/2026-05-15-insta-agent-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
1785
docs/superpowers/plans/2026-05-16-insta-trends-implementation.md
Normal file
1785
docs/superpowers/plans/2026-05-16-insta-trends-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
635
docs/superpowers/plans/2026-05-18-plan-b-base-redis-wsl2.md
Normal file
635
docs/superpowers/plans/2026-05-18-plan-b-base-redis-wsl2.md
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
# Plan-B-Base — NAS Redis 컨테이너 + Windows WSL2/Docker/Tailscale/SMB Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 분산 아키텍처 base 인프라 셋업 — NAS에 24/7 Redis 컨테이너 신설 + Windows AI 머신에 WSL2 + Docker Engine + Tailscale + NAS SMB 마운트 구성. 후속 Plan-B-Insta/Music/Video/Infra 트랙의 prerequisite.
|
||||||
|
|
||||||
|
**Architecture:** SP-1 (NAS Redis) = docker-compose service 추가 + deployer auto-rebuild. SP-2 (Windows) = 박재오 머신 192.168.45.59에서 직접 셋업 (WSL2 Ubuntu 22.04 + Docker Engine + Tailscale + cifs-utils로 NAS SMB 마운트). 두 SP가 모두 끝나야 후속 트랙의 worker가 NAS ↔ Windows 양방향 통신 가능.
|
||||||
|
|
||||||
|
**Tech Stack:** Redis 7-alpine, WSL2, Ubuntu 22.04, Docker Engine 24+, Tailscale, cifs-utils (SMB 3.0). PowerShell (관리자) + bash (WSL2 내부).
|
||||||
|
|
||||||
|
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-1·SP-2, §10 SP-1·SP-2 상세
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사전 확인 사항
|
||||||
|
|
||||||
|
- **박재오 자격증명 필요**: NAS SMB 마운트용 user/password (Synology DSM 사용자, SMB 권한 보유)
|
||||||
|
- **Windows AI 머신 직접 접근 필요**: WSL2 설치는 관리자 PowerShell + 재부팅 1회. Claude는 별도 머신이라 명령 직접 실행 불가 — **Task 4~7은 박재오가 콘솔에서 직접 수행**. 명령어와 검증 방법 명시.
|
||||||
|
- **NAS deployer 사용자**: Gitea webhook으로 docker compose up -d 자동 실행. 새 redis 서비스도 추가 시 자동 startup.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### SP-1 — NAS 측 (Modify)
|
||||||
|
|
||||||
|
| 파일 | 변경 | 책임 |
|
||||||
|
|------|------|------|
|
||||||
|
| `web-backend/docker-compose.yml` | `redis:` 서비스 블록 추가 | 컨테이너 정의 (image, volume, healthcheck) |
|
||||||
|
|
||||||
|
### SP-2 — Windows 측 (Create, 박재오 머신 로컬)
|
||||||
|
|
||||||
|
| 파일/위치 | 변경 | 책임 |
|
||||||
|
|----------|------|------|
|
||||||
|
| (Windows AI) WSL2 Ubuntu-22.04 | install | Linux 런타임 |
|
||||||
|
| WSL2 `/etc/apt/keyrings/docker.gpg` | install | Docker Engine apt key |
|
||||||
|
| WSL2 `/etc/apt/sources.list.d/docker.list` | install | Docker Engine apt source |
|
||||||
|
| (Windows AI) Tailscale | install + auth | 사설망 100.x.x.x |
|
||||||
|
| WSL2 `/etc/nas-smb-credentials` (신규) | NAS user/password | SMB 자격증명 (chmod 600) |
|
||||||
|
| WSL2 `/etc/fstab` (수정) | SMB 마운트 항목 추가 | 부팅 시 자동 마운트 |
|
||||||
|
| WSL2 `/mnt/nas` | mkdir | 마운트 포인트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: NAS docker-compose.yml에 redis 서비스 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 현재 docker-compose.yml 끝부분 확인 (deployer 위치)**
|
||||||
|
|
||||||
|
Run: `tail -20 C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
|
||||||
|
Expected: `deployer` 서비스가 마지막. line ~277-293 영역.
|
||||||
|
|
||||||
|
- [ ] **Step 2: redis 서비스 블록 추가**
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml` 파일 **끝**에 (deployer 서비스 다음, volumes 블록 있다면 그 전에) 다음 블록 추가. 들여쓰기는 다른 서비스(`lotto:`, `stock:` 등)와 동일하게 services 아래 2칸 들여쓰기:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- ${RUNTIME_PATH}/redis-data:/data
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
```
|
||||||
|
|
||||||
|
**주의:**
|
||||||
|
- 파일 끝에 추가하되, 만약 `networks:` / `volumes:` top-level 블록이 services 다음에 있다면 그 블록들 **앞에** 삽입
|
||||||
|
- 첫 줄에 빈 줄 1개 두기 (deployer와 분리)
|
||||||
|
- `${RUNTIME_PATH}` 환경변수는 다른 서비스에서도 사용 중. 자동 적용됨
|
||||||
|
|
||||||
|
- [ ] **Step 3: yaml 문법 검증**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python -c "import yaml; yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml'))" && echo "yaml OK"
|
||||||
|
```
|
||||||
|
Expected: `yaml OK`
|
||||||
|
|
||||||
|
만약 실패하면 indent 또는 trailing space 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 4: redis 서비스가 services dict에 들어갔는지 확인**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python -c "import yaml; d=yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml')); print(sorted(d['services'].keys()))"
|
||||||
|
```
|
||||||
|
Expected: 리스트에 `'redis'` 포함. 다른 서비스(`lotto`, `stock`, `music-lab`, `insta-lab`, `realestate-lab`, `agent-office`, `personal`, `packs-lab`, `travel-proxy`, `frontend`, `deployer`)도 모두 그대로.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git add docker-compose.yml
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(infra): add redis container as 24/7 queue + cache base (SP-1)
|
||||||
|
|
||||||
|
redis:7-alpine, 256MB maxmemory, AOF appendonly ON, allkeys-lru.
|
||||||
|
docker volume ${RUNTIME_PATH}/redis-data로 영속화.
|
||||||
|
Plan-B 후속 트랙(insta-render/music-render/video-render Windows
|
||||||
|
워커)의 BLPOP 큐 + NAS↔Windows pub/sub의 base.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: push (Gitea webhook → NAS deployer 자동 적용)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
자격증명 prompt 시 입력. 1회 실패 시 1회 재시도 패턴.
|
||||||
|
|
||||||
|
Expected: push 성공. NAS deployer가 webhook 수신 → `git pull` → `docker compose up -d redis` 자동 실행.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: NAS Redis 컨테이너 헬스 확인
|
||||||
|
|
||||||
|
**Files:** 없음 (NAS 검증)
|
||||||
|
|
||||||
|
- [ ] **Step 1: deployer 완료까지 대기 (통상 30초~2분)**
|
||||||
|
|
||||||
|
Run (Windows 로컬에서):
|
||||||
|
```bash
|
||||||
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" https://gahusb.synology.me/api/stock/news -m 5)
|
||||||
|
echo "[try $i] HTTP $code"
|
||||||
|
if [ "$code" = "200" ]; then break; fi
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: HTTP 200 응답 — NAS 컨테이너 안정 상태. redis 컨테이너는 별도 endpoint 없으나 deployer가 build 완료했음을 시사.
|
||||||
|
|
||||||
|
- [ ] **Step 2: NAS에서 redis 컨테이너 확인 (박재오 SSH)**
|
||||||
|
|
||||||
|
NAS bash:
|
||||||
|
```bash
|
||||||
|
ssh -p 22 박재오@gahusb.synology.me
|
||||||
|
cd /volume1/docker/webpage
|
||||||
|
docker compose ps redis
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 한 번에:
|
||||||
|
```bash
|
||||||
|
ssh -p 22 박재오@gahusb.synology.me "cd /volume1/docker/webpage && docker compose ps redis && docker exec redis redis-cli PING"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `docker compose ps redis` → `redis ... healthy` 또는 `Up X seconds (health: starting)` 후 곧 healthy
|
||||||
|
- `redis-cli PING` → `PONG`
|
||||||
|
|
||||||
|
만약 `docker compose ps`에 redis가 안 보이면:
|
||||||
|
```bash
|
||||||
|
cd /volume1/docker/webpage && docker compose up -d redis
|
||||||
|
```
|
||||||
|
|
||||||
|
수동 실행해서 startup 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 3: redis-data 볼륨 생성 확인 (Z: drive로)**
|
||||||
|
|
||||||
|
Run (Windows):
|
||||||
|
```powershell
|
||||||
|
Test-Path "Z:\webpage\redis-data"
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 NAS bash:
|
||||||
|
```bash
|
||||||
|
ls -la /volume1/docker/webpage/redis-data/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 디렉토리 존재. 그 안에 `appendonlydir/` 또는 `dump.rdb` 등의 redis 데이터 파일.
|
||||||
|
|
||||||
|
- [ ] **Step 4: AOF append-only 작동 확인 (선택, 데이터 영속성 검증)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli SET test_key "hello"'
|
||||||
|
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli RESTART' # 또는 docker restart
|
||||||
|
# 잠시 대기
|
||||||
|
ssh -p 22 박재오@gahusb.synology.me 'docker exec redis redis-cli GET test_key'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `"hello"` — 재시작 후에도 값 유지 (AOF 영속화 작동).
|
||||||
|
|
||||||
|
테스트 후 정리: `docker exec redis redis-cli DEL test_key`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Windows AI에 WSL2 + Ubuntu 22.04 설치
|
||||||
|
|
||||||
|
**Files:** 없음 (Windows AI 머신 192.168.45.59에서 박재오 직접 실행)
|
||||||
|
|
||||||
|
**전제:** Windows 10 build 19041+ 또는 Windows 11. 박재오 9800X3D 머신 충족.
|
||||||
|
|
||||||
|
- [ ] **Step 1: 관리자 PowerShell 실행**
|
||||||
|
|
||||||
|
박재오 Windows AI 머신에서 시작 메뉴 → "PowerShell" 우클릭 → "관리자 권한으로 실행".
|
||||||
|
|
||||||
|
- [ ] **Step 2: WSL2 + Ubuntu 22.04 설치**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl --install -d Ubuntu-22.04
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 다운로드 progress + "Ubuntu-22.04 has been installed". **재부팅 필요할 수 있음.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: 재부팅 (필요 시)**
|
||||||
|
|
||||||
|
설치 완료 메시지에 "재시작이 필요합니다"가 보이면 재부팅. 자동 재부팅 안 됨.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Ubuntu 초기 설정 (재부팅 후 자동 실행 또는 시작 메뉴에서 "Ubuntu" 클릭)**
|
||||||
|
|
||||||
|
새 콘솔이 열리고 다음 입력 요청됨:
|
||||||
|
- 새 UNIX username: `jaeoh` 또는 박재오 선호 username (이후 모든 sudo에 사용)
|
||||||
|
- 비밀번호: 박재오가 정하는 값. 잘 기억할 것.
|
||||||
|
|
||||||
|
Expected: `jaeoh@<hostname>:~$` 프롬프트 표시 → WSL2 진입 성공.
|
||||||
|
|
||||||
|
- [ ] **Step 5: WSL 버전 확인**
|
||||||
|
|
||||||
|
WSL2 내부에서 PowerShell로 잠시 돌아와서:
|
||||||
|
```powershell
|
||||||
|
wsl -l -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
```
|
||||||
|
NAME STATE VERSION
|
||||||
|
* Ubuntu-22.04 Running 2
|
||||||
|
```
|
||||||
|
|
||||||
|
VERSION=2 확인. 만약 1이면:
|
||||||
|
```powershell
|
||||||
|
wsl --set-version Ubuntu-22.04 2
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: WSL2 안 진입 (이후 작업)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl -d Ubuntu-22.04
|
||||||
|
```
|
||||||
|
|
||||||
|
이후 Task 4~7은 모두 WSL2 안 bash에서 실행.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: WSL2 안 Docker Engine 설치 (Docker Desktop 사용 X)
|
||||||
|
|
||||||
|
**Files:** (WSL2 내부) `/etc/apt/keyrings/docker.gpg`, `/etc/apt/sources.list.d/docker.list`
|
||||||
|
|
||||||
|
**위치:** WSL2 Ubuntu-22.04 bash 프롬프트.
|
||||||
|
|
||||||
|
- [ ] **Step 1: 패키지 인덱스 + 기본 의존성 설치**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y ca-certificates curl gnupg lsb-release
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 에러 없이 완료.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Docker apt key 등록**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 에러 없이 완료. `/etc/apt/keyrings/docker.gpg` 파일 생성.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Docker repository 추가**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
|
||||||
|
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
sudo apt update
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Hit:N https://download.docker.com/linux/ubuntu jammy InRelease` 라인 보임.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Docker Engine + Compose 설치**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 설치 완료. 용량 ~400MB.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 현재 사용자를 docker 그룹에 추가**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 출력 없음 (정상). **새 셸 열어야 적용됨.**
|
||||||
|
|
||||||
|
- [ ] **Step 6: Docker 서비스 시작 + 자동 시작 설정**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable docker
|
||||||
|
sudo systemctl start docker
|
||||||
|
sudo systemctl status docker | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Active: active (running)`.
|
||||||
|
|
||||||
|
만약 `systemctl: command not found` 또는 systemd 미지원 시:
|
||||||
|
```bash
|
||||||
|
sudo service docker start
|
||||||
|
```
|
||||||
|
|
||||||
|
WSL2 systemd 활성화는 `/etc/wsl.conf`에 `[boot]\nsystemd=true` 추가 후 PowerShell에서 `wsl --shutdown` 후 재진입. (Ubuntu-22.04는 보통 기본 활성)
|
||||||
|
|
||||||
|
- [ ] **Step 7: docker 명령 동작 확인**
|
||||||
|
|
||||||
|
새 셸로 (PowerShell에서 다시 `wsl -d Ubuntu-22.04` 또는 현재 셸 종료 후 재진입):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker version
|
||||||
|
docker run --rm hello-world
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `docker version`: Client + Server 둘 다 표시 (Server에 Engine version)
|
||||||
|
- `hello-world`: "Hello from Docker!" 출력
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: WSL2 안 Tailscale 설치 + 가입
|
||||||
|
|
||||||
|
**Files:** Tailscale은 systemd service 등록 (별도 path 신경 안 써도 됨)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Tailscale 설치**
|
||||||
|
|
||||||
|
WSL2 bash:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://tailscale.com/install.sh | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 패키지 install 후 "Installation complete!" 출력.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Tailscale 가입 (브라우저 OAuth)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tailscale up
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `To authenticate, visit: https://login.tailscale.com/a/...` URL 표시.
|
||||||
|
|
||||||
|
브라우저에서 그 URL 열기 → Google/Microsoft/GitHub 등으로 로그인 → 박재오 Tailscale 네트워크에 가입 (기존 계정 없으면 생성).
|
||||||
|
|
||||||
|
- [ ] **Step 3: 가입 완료 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tailscale status
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- 첫 줄에 Windows AI 머신의 100.x.x.x IP 표시
|
||||||
|
- (이미 가입된) NAS도 같은 네트워크에 있다면 NAS의 100.x.x.x IP도 표시
|
||||||
|
|
||||||
|
- [ ] **Step 4: NAS와 Tailscale ping (양방향 사설망 확인)**
|
||||||
|
|
||||||
|
NAS의 Tailscale IP를 `tailscale status` 출력에서 찾아 (예: `100.64.0.10`):
|
||||||
|
```bash
|
||||||
|
tailscale ping 100.64.0.10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `pong from <NAS hostname>` (직접 LAN 또는 DERP 중계). 만약 NAS가 Tailscale 미가입이면 별도로 NAS DSM Tailscale 패키지 셋업 필요 — 이는 박재오 결정 사항이라 plan 외.
|
||||||
|
|
||||||
|
> **참고:** Tailscale은 spec §3 sense의 사설망 layer 보조. LAN(192.168.45.0/24) 안에서만 작업한다면 Tailscale 없이도 작동. 외부 출장 등에서 NAS↔Windows 통신을 위해 권장.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: WSL2 안 NAS SMB 자격증명 파일 + 마운트 포인트 준비
|
||||||
|
|
||||||
|
**Files:** `/etc/nas-smb-credentials`, `/mnt/nas`
|
||||||
|
|
||||||
|
- [ ] **Step 1: cifs-utils 설치 (SMB 마운트 패키지)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y cifs-utils
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 설치 완료.
|
||||||
|
|
||||||
|
- [ ] **Step 2: SMB 자격증명 파일 생성**
|
||||||
|
|
||||||
|
박재오 NAS 계정의 username과 password를 사용. 파일 위치는 system-wide `/etc/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash -c 'cat > /etc/nas-smb-credentials <<EOF
|
||||||
|
username=박재오NAS사용자명
|
||||||
|
password=박재오NAS비밀번호
|
||||||
|
domain=
|
||||||
|
EOF'
|
||||||
|
```
|
||||||
|
|
||||||
|
**위 명령 실행 전 `박재오NAS사용자명` / `박재오NAS비밀번호`를 실제 값으로 교체.** Synology DSM Control Panel → User & Group 에서 SMB 접근 권한 있는 계정 사용. 비밀번호에 특수문자 있을 시 escape 필요 (특히 `!`, `$`, `\`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: 자격증명 파일 권한 보호**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chmod 600 /etc/nas-smb-credentials
|
||||||
|
sudo chown root:root /etc/nas-smb-credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 출력 없음.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la /etc/nas-smb-credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `-rw------- 1 root root ... /etc/nas-smb-credentials`
|
||||||
|
|
||||||
|
- [ ] **Step 4: 마운트 포인트 생성**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /mnt/nas
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: NAS SMB 마운트 (수동 마운트 + fstab 자동화)
|
||||||
|
|
||||||
|
**Files:** `/etc/fstab` (수정)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 수동 마운트 시도 (자격증명·경로 검증)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mount -t cifs //gahusb.synology.me/docker /mnt/nas \
|
||||||
|
-o credentials=/etc/nas-smb-credentials,vers=3.0,uid=1000,gid=1000,_netdev
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 출력 없음 (성공). 만약 `mount error(13)` (permission) → 자격증명 오류. `mount error(2)` (no such file) → share name `docker` 확인.
|
||||||
|
|
||||||
|
> **share name 변형:** 박재오 NAS는 메모리(`feedback_nas_deploy_paths.md`)에 따르면 SMB 매핑이 `/volume1/docker/`를 share `docker`로 노출. 만약 다른 share name(예: `webpage`)이라면 그것으로 교체.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 마운트 결과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /mnt/nas/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `webpage/` 디렉토리 + 다른 share 내 디렉토리 보임.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /mnt/nas/webpage/data/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `insta/`, `music/` 등 후속 트랙에서 사용할 디렉토리. 없으면 후속 트랙에서 생성됨.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 마운트 해제 후 fstab으로 자동화**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo umount /mnt/nas
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 출력 없음.
|
||||||
|
|
||||||
|
`/etc/fstab` 끝에 다음 라인 추가:
|
||||||
|
```bash
|
||||||
|
sudo bash -c 'cat >> /etc/fstab <<EOF
|
||||||
|
|
||||||
|
# NAS Synology SMB mount for web-ai-services workers (2026-05-18)
|
||||||
|
//gahusb.synology.me/docker /mnt/nas cifs credentials=/etc/nas-smb-credentials,vers=3.0,uid=1000,gid=1000,_netdev,nofail 0 0
|
||||||
|
EOF'
|
||||||
|
```
|
||||||
|
|
||||||
|
`nofail` 옵션은 부팅 시 NAS 미접속이어도 boot 진행 (production 안전).
|
||||||
|
|
||||||
|
- [ ] **Step 4: fstab 적용 + 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mount -a
|
||||||
|
ls /mnt/nas/webpage/data/ 2>&1 | head -5
|
||||||
|
mount | grep cifs
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `mount -a` 출력 없음 (성공)
|
||||||
|
- `ls /mnt/nas/webpage/data/` 디렉토리 내용 표시
|
||||||
|
- `mount | grep cifs` 라인에 마운트 정보 보임
|
||||||
|
|
||||||
|
- [ ] **Step 5: WSL2 재시작 시 자동 마운트 확인**
|
||||||
|
|
||||||
|
PowerShell에서 (관리자 권한 불필요):
|
||||||
|
```powershell
|
||||||
|
wsl --shutdown
|
||||||
|
wsl -d Ubuntu-22.04
|
||||||
|
```
|
||||||
|
|
||||||
|
WSL2 다시 진입 후:
|
||||||
|
```bash
|
||||||
|
ls /mnt/nas/webpage/data/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 정상 디렉토리 목록. 자동 마운트 성공.
|
||||||
|
|
||||||
|
만약 마운트 안 됨:
|
||||||
|
- `dmesg | grep cifs` 확인
|
||||||
|
- `nofail` 때문에 boot은 통과했으나 마운트 실패 가능. 수동 `sudo mount -a` 후 동작 확인 → fstab syntax 재검토
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: 통합 검증 — base 인프라 동작 확인
|
||||||
|
|
||||||
|
**Files:** 없음 (검증)
|
||||||
|
|
||||||
|
- [ ] **Step 1: NAS Redis 외부 ping (Windows 로컬에서)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows AI 또는 박재오 PC에서
|
||||||
|
Test-NetConnection -ComputerName 192.168.45.54 -Port 6379
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `TcpTestSucceeded : True`
|
||||||
|
|
||||||
|
> 외부 6379 노출은 LAN 한정. 가능하면 NAS firewall (DSM Control Panel)에서 6379 LAN-only allowed로 한정 권장. (이번 plan에 포함 안 됨, 별도 사용자 작업)
|
||||||
|
|
||||||
|
- [ ] **Step 2: WSL2에서 NAS Redis 접속**
|
||||||
|
|
||||||
|
WSL2 bash:
|
||||||
|
```bash
|
||||||
|
docker run --rm redis:7-alpine redis-cli -h 192.168.45.54 PING
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 Tailscale 사용 시:
|
||||||
|
```bash
|
||||||
|
docker run --rm redis:7-alpine redis-cli -h <NAS_TAILSCALE_IP> PING
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `PONG`
|
||||||
|
|
||||||
|
- [ ] **Step 3: NAS volume 쓰기 테스트 (Windows→NAS 양방향)**
|
||||||
|
|
||||||
|
WSL2 bash:
|
||||||
|
```bash
|
||||||
|
echo "Plan-B-Base test $(date)" | sudo tee /mnt/nas/webpage/data/.plan-b-test.txt
|
||||||
|
cat /mnt/nas/webpage/data/.plan-b-test.txt
|
||||||
|
sudo rm /mnt/nas/webpage/data/.plan-b-test.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `tee` 출력에 같은 내용 + 파일 생성됨
|
||||||
|
- `cat` 으로 확인 성공
|
||||||
|
- 파일 삭제 성공
|
||||||
|
|
||||||
|
`sudo` 필요 시 chmod로 uid 1000 쓰기 권한 확인. 또는 mount option `uid=1000,gid=1000` 적용 후 일반 사용자도 쓰기 가능. 만약 안 되면 NAS DSM에서 SMB user의 write 권한 확인.
|
||||||
|
|
||||||
|
- [ ] **Step 4: WSL2 Docker로 hello-world 한 번 더 (재진입 후 상태 확인)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm hello-world
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: "Hello from Docker!"
|
||||||
|
|
||||||
|
- [ ] **Step 5: 모든 검증 완료 후 보고 — 후속 트랙으로 진입 가능 상태**
|
||||||
|
|
||||||
|
다음 plan(Plan-B-Insta 등)이 가정하는 상태:
|
||||||
|
- ✅ NAS `redis:6379` PING/PONG 성공
|
||||||
|
- ✅ Windows WSL2 Ubuntu-22.04 작동 + Docker Engine 실행
|
||||||
|
- ✅ `/mnt/nas/webpage/data/` 양방향 read·write 성공
|
||||||
|
- ✅ Tailscale 가입 (선택, 외부 출장 시 필요)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
### Spec 커버리지
|
||||||
|
|
||||||
|
| Spec 요구사항 | 구현 Task |
|
||||||
|
|---------------|-----------|
|
||||||
|
| §4 SP-1: NAS Redis 컨테이너 | Task 1 (compose 추가) + Task 2 (헬스 검증) |
|
||||||
|
| §10 SP-1: redis:7-alpine + 256MB + AOF + healthcheck | Task 1 Step 2 |
|
||||||
|
| §4 SP-2: Windows WSL2 + Docker Engine | Task 3 (WSL2) + Task 4 (Docker) |
|
||||||
|
| §10 SP-2: Tailscale | Task 5 |
|
||||||
|
| §10 SP-2: NAS SMB mount `/mnt/nas` | Task 6 (자격증명·포인트) + Task 7 (마운트+fstab) |
|
||||||
|
| §10 SP-2: 검증 (docker ps, tailscale status, ls /mnt/nas) | Task 8 |
|
||||||
|
| §6 Redis 키 컨벤션 사용 가능 | Task 2 Step 2 (PING) — 컨벤션 자체는 후속 트랙에서 RPUSH로 시작 |
|
||||||
|
|
||||||
|
### Placeholder 스캔
|
||||||
|
|
||||||
|
- TBD/TODO 없음 ✓
|
||||||
|
- 모든 명령어가 그대로 실행 가능한 형태 ✓
|
||||||
|
- 한 가지 예외: Task 6 Step 2 — `박재오NAS사용자명/박재오NAS비밀번호`는 사용자 자격증명이라 placeholder가 의도된 것. 실행 전 교체 명시 ✓
|
||||||
|
- Task 5 Step 4 — `<NAS 의 Tailscale IP>`는 `tailscale status` 출력에서 박재오가 보고 입력. 사용자 환경에서만 결정 가능, plan에 명시 ✓
|
||||||
|
|
||||||
|
### Type/이름 consistency
|
||||||
|
|
||||||
|
- `redis` 서비스명 (Task 1, 2, 8 모두 동일) ✓
|
||||||
|
- `/mnt/nas` 마운트 포인트 (Task 6, 7, 8 모두 동일) ✓
|
||||||
|
- `/etc/nas-smb-credentials` 자격증명 파일 (Task 6, 7 동일) ✓
|
||||||
|
- share name `docker` (Task 7 Step 1, fstab 동일) ✓
|
||||||
|
- Ubuntu-22.04 (Task 3, 4 동일) ✓
|
||||||
|
|
||||||
|
### 위험·주의
|
||||||
|
|
||||||
|
| 위험 | 완화 |
|
||||||
|
|------|------|
|
||||||
|
| Windows 재부팅 시 WSL2 자동 시작 안 함 | 향후 Plan-B-Infra(SP-9)에서 NSSM으로 자동 시작 |
|
||||||
|
| WSL2 systemd 미지원 시 docker service 자동 시작 안 함 | Task 4 Step 6의 fallback `sudo service docker start` 또는 `/etc/wsl.conf` 수정 |
|
||||||
|
| SMB 마운트 자격증명 노출 | `/etc/nas-smb-credentials` chmod 600 + root:root |
|
||||||
|
| NAS firewall에서 6379 외부 노출 | 권장: LAN(192.168.45.0/24) only allow. 본 plan 외 (DSM 수동) |
|
||||||
|
| Tailscale 미가입 시 NAS↔Windows 외부 통신 불가 | LAN 내에선 작동. 외부 출장 시 필요할 때만 가입 |
|
||||||
|
| /mnt/nas 쓰기 권한 부족 | uid=1000 mount option + NAS DSM에서 SMB user의 share write 권한 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 후 다음 단계
|
||||||
|
|
||||||
|
Plan-B-Base 완료 후 spec §14 권장 순서대로:
|
||||||
|
|
||||||
|
1. **Plan-B-Insta** — SP-3 (insta-render Windows worker) + SP-4 (NAS insta-lab 분할)
|
||||||
|
2. **Plan-B-Music** — SP-5 + SP-6
|
||||||
|
3. **Plan-B-Video** — SP-7 + SP-8
|
||||||
|
4. **Plan-B-Infra** — SP-9 (NSSM 자동 시작) + SP-10 (task-watcher)
|
||||||
|
|
||||||
|
각 후속 plan은 본 plan이 제공한 base 인프라(Redis + WSL2/Docker + /mnt/nas)에 의존.
|
||||||
656
docs/superpowers/plans/2026-05-18-track-a-cache-hardening.md
Normal file
656
docs/superpowers/plans/2026-05-18-track-a-cache-hardening.md
Normal file
@@ -0,0 +1,656 @@
|
|||||||
|
# Track A — NAS↔Windows API 부하 캐시 강화 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** web-ai → NAS stock 호출량을 분당 12회 → 분당 3~4회로 축소하여, V2 재시작 시점부터 즉시 NAS CPU 부담 70% 감소.
|
||||||
|
|
||||||
|
**Architecture:** 2-layer cache. (1) web-ai client side: 3개 endpoint TTL 60/300/60 → 180/600/300으로 증가. (2) NAS stock server side: 동일 endpoint에 in-memory TTLCache 추가하여 web-ai 캐시 miss 시에도 KIS·LLM 재호출 차단. 두 layer가 cumulative하게 작동.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12 / FastAPI / pytest / `cachetools.TTLCache`. **two repos**: `web-ai` (signal_v2/) + `web-backend` (stock/).
|
||||||
|
|
||||||
|
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-A1·A2, §10 상세
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### SP-A1 — web-ai 캐시 TTL (Modify)
|
||||||
|
|
||||||
|
| 파일 | 변경 | 책임 |
|
||||||
|
|------|------|------|
|
||||||
|
| `web-ai/signal_v2/stock_client.py:13-17` | `_TTL` dict 3개 값 변경 | endpoint별 client-side cache TTL |
|
||||||
|
| `web-ai/signal_v2/tests/test_stock_client_ttl.py` (Create) | TTL 값 회귀 테스트 | 미래 변경 시 의도하지 않은 회귀 방지 |
|
||||||
|
|
||||||
|
### SP-A2 — NAS stock TTLCache (Modify + Create)
|
||||||
|
|
||||||
|
| 파일 | 변경 | 책임 |
|
||||||
|
|------|------|------|
|
||||||
|
| `web-backend/stock/requirements.txt` | `cachetools>=5.3` 추가 | 의존성 |
|
||||||
|
| `web-backend/stock/app/webai_cache.py` (Create) | 3개 TTLCache + helper 함수 | server-side cache 중앙화 |
|
||||||
|
| `web-backend/stock/app/main.py:419-422` | `get_webai_portfolio()` cache 적용 | NAS portfolio 캐시 |
|
||||||
|
| `web-backend/stock/app/main.py:467-470` | `get_webai_news_sentiment(date)` cache 적용 | date별 캐시 |
|
||||||
|
| `web-backend/stock/app/screener/router.py:173` | `post_run()` cache 적용 (mode=preview만) | screener preview 캐시 |
|
||||||
|
| `web-backend/stock/app/test_webai_cache.py` (Create) | cache 동작 + TTL + key 분기 | 캐시 hit/miss 검증 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: web-ai SP-A1 — `_TTL` dict 회귀 테스트 작성
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/tests/test_stock_client_ttl.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_stock_client_ttl.py
|
||||||
|
"""SP-A1 회귀 — _TTL이 NAS 부담 완화를 위한 값으로 설정되어 있어야 함."""
|
||||||
|
from signal_v2.stock_client import _TTL
|
||||||
|
|
||||||
|
|
||||||
|
def test_portfolio_ttl_is_180s():
|
||||||
|
"""portfolio TTL은 180초 이상 (3분 폴링에서 1회 fetch가 3 폴링 커버)."""
|
||||||
|
assert _TTL["portfolio"] >= 180.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_news_sentiment_ttl_is_600s():
|
||||||
|
"""news-sentiment TTL은 600초 이상 (10분, 뉴스 sentiment는 자주 안 바뀜)."""
|
||||||
|
assert _TTL["news-sentiment"] >= 600.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_screener_preview_ttl_is_300s():
|
||||||
|
"""screener-preview TTL은 300초 이상 (5분, Top-20은 분 단위로 거의 안 바뀜)."""
|
||||||
|
assert _TTL["screener-preview"] >= 300.0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
|
||||||
|
Expected: FAIL — 현재 _TTL 값은 60/300/60. portfolio·screener-preview 모두 < 180/300.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `_TTL` 값 변경**
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/signal_v2/stock_client.py` line 13-17:
|
||||||
|
|
||||||
|
변경 전:
|
||||||
|
```python
|
||||||
|
_TTL = {
|
||||||
|
"portfolio": 60.0,
|
||||||
|
"news-sentiment": 300.0,
|
||||||
|
"screener-preview": 60.0,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
# Cache TTL by endpoint (seconds).
|
||||||
|
# 2026-05-18 — NAS 인바운드 호출 부담 완화 (Plan-A SP-A1).
|
||||||
|
_TTL = {
|
||||||
|
"portfolio": 180.0, # 3분 (1분 폴링 시 3 폴링당 1회 실제 fetch)
|
||||||
|
"news-sentiment": 600.0, # 10분 (뉴스 sentiment는 자주 안 바뀜)
|
||||||
|
"screener-preview": 300.0, # 5분 (Top-20은 분 단위로 거의 안 바뀜)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/test_stock_client_ttl.py -v`
|
||||||
|
Expected: PASS — 3개 모두 통과.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 전체 회귀 확인 (기존 56 tests + 신규 3 tests)**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai && python -m pytest signal_v2/tests/ -v 2>&1 | tail -5`
|
||||||
|
Expected: 59 tests 모두 PASS (기존 56 + 신규 3).
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git add signal_v2/stock_client.py signal_v2/tests/test_stock_client_ttl.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
perf(signal_v2): raise stock_client TTL for NAS load relief (SP-A1)
|
||||||
|
|
||||||
|
portfolio 60s → 180s (3분 폴링 → 3회당 1회 fetch)
|
||||||
|
news-sent 300s → 600s (sentiment는 자주 안 바뀜)
|
||||||
|
screener 60s → 300s (Top-20 분 단위 변화 미미)
|
||||||
|
|
||||||
|
V2 재시작 시점부터 NAS stock에 대한 인바운드 호출이
|
||||||
|
분당 12 → 분당 3~4 로 감소 예상. 캐시 hit ratio 0~50% → 66~80%.
|
||||||
|
회귀 테스트 3건 추가로 미래 의도치 않은 TTL 변경 차단.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: NAS SP-A2 — `cachetools` 의존성 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 현재 requirements.txt 확인**
|
||||||
|
|
||||||
|
Run: `cat C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt`
|
||||||
|
파일 끝 확인 — 마지막 줄 newline 여부 확인 (sed/append 안전).
|
||||||
|
|
||||||
|
- [ ] **Step 2: cachetools 추가**
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/requirements.txt` 끝에 한 줄 추가:
|
||||||
|
|
||||||
|
```
|
||||||
|
cachetools>=5.3
|
||||||
|
```
|
||||||
|
|
||||||
|
(파일 마지막에 newline 없으면 newline 먼저, 그 다음 cachetools 줄.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 로컬 import 가능 여부 확인 (선택, NAS rebuild가 정본)**
|
||||||
|
|
||||||
|
Run (Windows 로컬에서 docker 외부 검증용, 선택):
|
||||||
|
```bash
|
||||||
|
python -c "import cachetools; print(cachetools.__version__)" 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
로컬 미설치라면 skip — NAS deployer가 rebuild 시 install. 이 plan은 코드 정합성만 보장.
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋 (단독 커밋, deps만)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git add stock/requirements.txt
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
chore(stock): add cachetools for server-side TTLCache (SP-A2 prep)
|
||||||
|
|
||||||
|
다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run에
|
||||||
|
in-memory TTLCache 적용 예정.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: NAS SP-A2 — `webai_cache.py` 모듈 + 단위 테스트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_webai_cache.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""SP-A2 — webai_cache module의 cache hit/miss + key 분기 검증."""
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
from app.webai_cache import (
|
||||||
|
PORTFOLIO_CACHE, NEWS_CACHE, SCREENER_CACHE,
|
||||||
|
cache_get_portfolio, cache_set_portfolio,
|
||||||
|
cache_get_news, cache_set_news,
|
||||||
|
cache_get_screener, cache_set_screener,
|
||||||
|
_screener_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_all():
|
||||||
|
PORTFOLIO_CACHE.clear()
|
||||||
|
NEWS_CACHE.clear()
|
||||||
|
SCREENER_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_portfolio_cache_miss_then_hit():
|
||||||
|
_clear_all()
|
||||||
|
assert cache_get_portfolio() is None
|
||||||
|
cache_set_portfolio({"holdings": [], "cash": 0})
|
||||||
|
assert cache_get_portfolio() == {"holdings": [], "cash": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_news_cache_key_by_date():
|
||||||
|
"""date가 다르면 별도 캐시 슬롯."""
|
||||||
|
_clear_all()
|
||||||
|
cache_set_news("2026-05-18", {"count": 5})
|
||||||
|
cache_set_news("2026-05-17", {"count": 3})
|
||||||
|
assert cache_get_news("2026-05-18") == {"count": 5}
|
||||||
|
assert cache_get_news("2026-05-17") == {"count": 3}
|
||||||
|
assert cache_get_news("2026-05-16") is None # not cached
|
||||||
|
|
||||||
|
|
||||||
|
def test_news_cache_latest_key_normalized():
|
||||||
|
"""date=None은 'latest' 키로 정규화되어 동일 슬롯."""
|
||||||
|
_clear_all()
|
||||||
|
cache_set_news(None, {"count": 9})
|
||||||
|
assert cache_get_news(None) == {"count": 9}
|
||||||
|
|
||||||
|
|
||||||
|
def test_screener_key_includes_mode_and_top_n():
|
||||||
|
"""screener key는 mode + top_n + weights hash로 분기."""
|
||||||
|
k_preview = _screener_key("preview", 20, None)
|
||||||
|
k_preview_w = _screener_key("preview", 20, {"news": 0.3})
|
||||||
|
k_auto = _screener_key("auto", 20, None)
|
||||||
|
assert k_preview != k_preview_w
|
||||||
|
assert k_preview != k_auto
|
||||||
|
|
||||||
|
|
||||||
|
def test_screener_cache_roundtrip():
|
||||||
|
_clear_all()
|
||||||
|
payload = {"asof": "2026-05-18", "survivors_count": 17}
|
||||||
|
cache_set_screener("preview", 20, None, payload)
|
||||||
|
assert cache_get_screener("preview", 20, None) == payload
|
||||||
|
assert cache_get_screener("preview", 20, {"news": 0.3}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ttl_expiry_portfolio():
|
||||||
|
"""짧은 ttl로 만료 확인 — 직접 시간 조작 대신 TTLCache 내부 동작 신뢰."""
|
||||||
|
from cachetools import TTLCache
|
||||||
|
short = TTLCache(maxsize=1, ttl=0.1) # 0.1초
|
||||||
|
short["result"] = "x"
|
||||||
|
assert short.get("result") == "x"
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert short.get("result") is None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
|
||||||
|
Expected: FAIL — `app.webai_cache` 모듈 존재 안 함.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `webai_cache.py` 작성**
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/webai_cache.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""SP-A2 — NAS stock의 /api/webai/* 엔드포인트 in-memory TTLCache.
|
||||||
|
|
||||||
|
web-ai 측 캐시(stock_client._TTL)가 miss됐을 때도 NAS에서 같은 데이터를
|
||||||
|
KIS·LLM 재호출 없이 즉시 반환하기 위한 2-layer 캐시의 server 측.
|
||||||
|
V1+V2가 동시 호출해도 NAS는 1회만 계산.
|
||||||
|
|
||||||
|
TTL 정책 (spec §10 SP-A2):
|
||||||
|
- portfolio: 120s (web-ai TTL 180s 보다 짧게 — 변경 감지 가능)
|
||||||
|
- news: 600s (sentiment는 일 단위)
|
||||||
|
- screener: 180s
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from cachetools import TTLCache
|
||||||
|
|
||||||
|
|
||||||
|
PORTFOLIO_CACHE: TTLCache = TTLCache(maxsize=1, ttl=120.0)
|
||||||
|
NEWS_CACHE: TTLCache = TTLCache(maxsize=10, ttl=600.0)
|
||||||
|
SCREENER_CACHE: TTLCache = TTLCache(maxsize=10, ttl=180.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ----- portfolio -----
|
||||||
|
|
||||||
|
def cache_get_portfolio() -> Optional[Any]:
|
||||||
|
return PORTFOLIO_CACHE.get("result")
|
||||||
|
|
||||||
|
|
||||||
|
def cache_set_portfolio(value: Any) -> None:
|
||||||
|
PORTFOLIO_CACHE["result"] = value
|
||||||
|
|
||||||
|
|
||||||
|
# ----- news-sentiment -----
|
||||||
|
|
||||||
|
def _news_key(date: Optional[str]) -> str:
|
||||||
|
return date if date else "latest"
|
||||||
|
|
||||||
|
|
||||||
|
def cache_get_news(date: Optional[str]) -> Optional[Any]:
|
||||||
|
return NEWS_CACHE.get(_news_key(date))
|
||||||
|
|
||||||
|
|
||||||
|
def cache_set_news(date: Optional[str], value: Any) -> None:
|
||||||
|
NEWS_CACHE[_news_key(date)] = value
|
||||||
|
|
||||||
|
|
||||||
|
# ----- screener -----
|
||||||
|
|
||||||
|
def _screener_key(mode: str, top_n: int, weights: Optional[dict]) -> str:
|
||||||
|
"""mode + top_n + weights canonical hash. weights 객체 동등성을 키로."""
|
||||||
|
if weights is None:
|
||||||
|
w_repr = "none"
|
||||||
|
else:
|
||||||
|
# canonical: sorted keys → md5 hex (긴 weights도 짧은 키로)
|
||||||
|
canon = json.dumps(weights, sort_keys=True, ensure_ascii=False)
|
||||||
|
w_repr = hashlib.md5(canon.encode("utf-8")).hexdigest()[:12]
|
||||||
|
return f"{mode}:{top_n}:{w_repr}"
|
||||||
|
|
||||||
|
|
||||||
|
def cache_get_screener(mode: str, top_n: int, weights: Optional[dict]) -> Optional[Any]:
|
||||||
|
return SCREENER_CACHE.get(_screener_key(mode, top_n, weights))
|
||||||
|
|
||||||
|
|
||||||
|
def cache_set_screener(mode: str, top_n: int, weights: Optional[dict], value: Any) -> None:
|
||||||
|
SCREENER_CACHE[_screener_key(mode, top_n, weights)] = value
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_webai_cache.py -v`
|
||||||
|
Expected: PASS — 6개 모두 통과.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git add stock/app/webai_cache.py stock/app/test_webai_cache.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(stock): webai_cache module (TTLCache for SP-A2)
|
||||||
|
|
||||||
|
3개의 TTLCache (portfolio 120s · news 600s · screener 180s) +
|
||||||
|
헬퍼 함수. screener key는 mode + top_n + weights canonical hash로
|
||||||
|
분기. 다음 커밋에서 /api/webai/portfolio·news-sentiment·screener/run
|
||||||
|
3 endpoint에 적용.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: NAS SP-A2 — `/api/webai/portfolio` 캐시 적용
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:419-422`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 현재 endpoint 코드 확인**
|
||||||
|
|
||||||
|
`web-backend/stock/app/main.py` 419-422 line은 spec §10 SP-A2와 일치:
|
||||||
|
```python
|
||||||
|
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_webai_portfolio():
|
||||||
|
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가)."""
|
||||||
|
return _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 캐시 적용으로 교체**
|
||||||
|
|
||||||
|
`web-backend/stock/app/main.py` 419-422 line을 다음으로 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/api/webai/portfolio", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_webai_portfolio():
|
||||||
|
"""web-ai 전용 portfolio (인증 필수, pnl_pct 비율 필드 추가).
|
||||||
|
|
||||||
|
SP-A2 server-side TTLCache 적용. V1+V2 동시 호출도 NAS에서 1회 계산.
|
||||||
|
"""
|
||||||
|
cached = webai_cache.cache_get_portfolio()
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
result = _augment_portfolio_with_pnl_pct(get_portfolio())
|
||||||
|
webai_cache.cache_set_portfolio(result)
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: import 추가 (파일 상단)**
|
||||||
|
|
||||||
|
`web-backend/stock/app/main.py` 파일 상단 import 블록 (다른 `from .xxx import` 들과 같은 위치)에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from . import webai_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 빠른 import sanity 체크**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')"` 2>&1 | tail -3
|
||||||
|
|
||||||
|
(`cachetools` 미설치 환경에선 ImportError 가능 → 그 경우 `pip install cachetools` 후 재시도. 실제 검증은 NAS rebuild 후.)
|
||||||
|
Expected: `OK` 또는 cachetools 누락 메시지 (의도된 상태).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: NAS SP-A2 — `/api/webai/news-sentiment` 캐시 적용
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py:467-470`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 캐시 적용**
|
||||||
|
|
||||||
|
`web-backend/stock/app/main.py` 467-470 line을 다음으로 교체:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_webai_news_sentiment(date: str | None = None):
|
||||||
|
"""web-ai 전용 news sentiment 일별 dump.
|
||||||
|
|
||||||
|
SP-A2 server-side TTLCache 적용. date 파라미터별로 별도 슬롯.
|
||||||
|
"""
|
||||||
|
cached = webai_cache.cache_get_news(date)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
result = _fetch_news_sentiment_dump(date)
|
||||||
|
webai_cache.cache_set_news(date, result)
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: import sanity 체크**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app import main; print('OK')" 2>&1 | tail -3`
|
||||||
|
Expected: `OK`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: NAS SP-A2 — `/api/stock/screener/run` 캐시 적용 (preview 모드만)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/screener/router.py:173-...`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 현재 함수 확인 (참고)**
|
||||||
|
|
||||||
|
`web-backend/stock/app/screener/router.py:173` 시작 `def post_run(body: schemas.RunRequest):` — 함수 본체는 mode 분기 후 _conn() + KIS 호출 등. 단, `mode == "auto"` 는 휴장일/실 운영 트리거이므로 캐시하지 않음 (매 호출이 다른 의미). `mode == "preview"` 는 frontend·web-ai 폴링용 → 캐시 적용.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 함수 진입부에 cache 분기 추가**
|
||||||
|
|
||||||
|
`web-backend/stock/app/screener/router.py:173` `@router.post("/run", ...)` 의 `def post_run(...)` 본체 **첫 줄들에** 다음 캐시 분기 추가:
|
||||||
|
|
||||||
|
변경 전 (line 173-179 근처):
|
||||||
|
```python
|
||||||
|
@router.post("/run", response_model=schemas.RunResponse)
|
||||||
|
def post_run(body: schemas.RunRequest):
|
||||||
|
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
|
||||||
|
started_at = dt.datetime.utcnow().isoformat()
|
||||||
|
with _conn() as c:
|
||||||
|
asof = _resolve_asof(body.asof, c)
|
||||||
|
```
|
||||||
|
|
||||||
|
변경 후:
|
||||||
|
```python
|
||||||
|
@router.post("/run", response_model=schemas.RunResponse)
|
||||||
|
def post_run(body: schemas.RunRequest):
|
||||||
|
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
|
||||||
|
# SP-A2 — preview 모드는 web-ai/frontend 폴링이라 캐시 적용.
|
||||||
|
# auto 모드는 실제 운영 트리거(휴장일 게이트 등)라 캐시 미적용.
|
||||||
|
if body.mode == "preview":
|
||||||
|
cached = webai_cache.cache_get_screener(body.mode, body.top_n, body.weights)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
started_at = dt.datetime.utcnow().isoformat()
|
||||||
|
with _conn() as c:
|
||||||
|
asof = _resolve_asof(body.asof, c)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 함수 끝 부분 — preview 결과를 캐시에 저장**
|
||||||
|
|
||||||
|
`post_run`의 반환부 직전에 (preview 모드일 때만) 캐시 저장. `post_run` 함수는 결과를 `schemas.RunResponse(...)` 로 만들어 return하는 구조일 것. 정확한 return 위치 확인 후, return 직전에:
|
||||||
|
|
||||||
|
`web-backend/stock/app/screener/router.py` `post_run` 함수의 마지막 return 직전에:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# SP-A2 — preview 모드 결과 캐시 저장.
|
||||||
|
if body.mode == "preview":
|
||||||
|
webai_cache.cache_set_screener(body.mode, body.top_n, body.weights, response)
|
||||||
|
return response
|
||||||
|
```
|
||||||
|
|
||||||
|
(`response` 라는 변수가 없으면, 기존 return 표현식을 `response = ...` 로 binding 후 위 코드 추가.)
|
||||||
|
|
||||||
|
> **주의:** post_run의 정확한 return 라인을 먼저 확인. `grep -n "return " app/screener/router.py | head` 로 위치 파악 후 적용.
|
||||||
|
|
||||||
|
- [ ] **Step 4: import 추가 (router.py 상단)**
|
||||||
|
|
||||||
|
`web-backend/stock/app/screener/router.py` 상단 import 블록에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .. import webai_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 빠른 import sanity 체크**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -c "from app.screener import router; print('OK')" 2>&1 | tail -3`
|
||||||
|
Expected: `OK`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 통합 검증 — 기존 테스트 회귀 + SP-A2 신규 테스트
|
||||||
|
|
||||||
|
**Files:** (조회만)
|
||||||
|
|
||||||
|
- [ ] **Step 1: stock 전체 pytest 실행**
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest -v 2>&1 | tail -30`
|
||||||
|
Expected: 기존 모든 테스트 + SP-A2 신규 6 tests 모두 PASS. **0 failed**.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 회귀 발견 시 처리**
|
||||||
|
|
||||||
|
회귀가 발견되면:
|
||||||
|
- import 누락 → `from . import webai_cache` 또는 `from .. import webai_cache` 위치 재확인
|
||||||
|
- screener test가 cache hit으로 fail → test가 `_clear_all()` 또는 cache fixture 통해 격리되어 있는지 확인. 필요 시 conftest에 `autouse=True` cache reset fixture 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# conftest.py에 추가 (선택)
|
||||||
|
import pytest
|
||||||
|
from app import webai_cache
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_webai_cache():
|
||||||
|
webai_cache.PORTFOLIO_CACHE.clear()
|
||||||
|
webai_cache.NEWS_CACHE.clear()
|
||||||
|
webai_cache.SCREENER_CACHE.clear()
|
||||||
|
yield
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋 (SP-A2 endpoint 통합 + 회귀 확인)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git add stock/app/main.py stock/app/screener/router.py
|
||||||
|
# (필요 시) git add stock/app/conftest.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(stock): apply webai_cache to portfolio/news/screener-preview (SP-A2)
|
||||||
|
|
||||||
|
3 endpoint cache 적용 — /api/webai/portfolio, /api/webai/news-sentiment,
|
||||||
|
/api/stock/screener/run (preview 모드만, auto는 캐시 미적용).
|
||||||
|
V1+V2 동시 호출도 NAS에서 1회 계산. web-ai 측 SP-A1 캐시와 2-layer로
|
||||||
|
작동하여 NAS 인바운드 부담 70% 감소 예상.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: 양쪽 push + NAS deploy 트리거
|
||||||
|
|
||||||
|
**Files:** 없음 (git 작업)
|
||||||
|
|
||||||
|
- [ ] **Step 1: web-ai push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: success. 인증 prompt 뜨면 자격증명 입력. 1회 실패 시 1회 재시도 (캐시 패턴).
|
||||||
|
|
||||||
|
> **참고:** web-ai는 NAS deployer가 별도 webhook 없음 (Windows 머신 코드). push는 백업/이력 동기화 목적. 실제 적용은 V2 재시작 시점.
|
||||||
|
|
||||||
|
- [ ] **Step 2: web-backend push (NAS deployer 트리거)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: success. NAS deployer가 webhook 수신 → `git pull` → `docker compose build stock --no-cache` (cachetools 신규 설치) → `docker compose up -d stock`. 통상 2~3분 소요.
|
||||||
|
|
||||||
|
- [ ] **Step 3: NAS stock 컨테이너 헬스 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://gahusb.synology.me/api/stock/news -m 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `HTTP 200`. (NAS deploy 완료 후 통상 30초 ~ 2분 대기 필요.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: webai 캐시 효과 확인 (선택)**
|
||||||
|
|
||||||
|
연속 2회 호출 시 두 번째가 즉시 응답하는지 (cached):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 인증키 필요. .env의 WEBAI_API_KEY 사용 또는 NAS에서 직접 호출.
|
||||||
|
# Windows 로컬에서:
|
||||||
|
# 첫 호출
|
||||||
|
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
|
||||||
|
# 즉시 두번째 (캐시 hit 기대, 첫 호출 < 1s + DB / 두번째 < 100ms)
|
||||||
|
time curl -s -H "X-WebAI-Key: $WEBAI_API_KEY" https://gahusb.synology.me/api/webai/portfolio -o /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 두 번째 호출이 첫 번째보다 명확히 빠름 (DB·계산 skip).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
### Spec 커버리지
|
||||||
|
|
||||||
|
| Spec 요구사항 | 구현 Task |
|
||||||
|
|---------------|-----------|
|
||||||
|
| §4 SP-A1: web-ai 캐시 TTL 증가 (180/600/300) | Task 1 |
|
||||||
|
| §4 SP-A2: NAS stock TTLCache | Task 2~7 |
|
||||||
|
| §10 SP-A2: 3 endpoint (portfolio/news/screener) 적용 | Task 4 (portfolio), Task 5 (news), Task 6 (screener preview) |
|
||||||
|
| §10 SP-A2: cachetools 의존성 | Task 2 |
|
||||||
|
| §8: X-WebAI-Key 인증 (기존 verify_webai_key 유지) | 기존 dependency 그대로, 변경 없음 |
|
||||||
|
| §6: server cache 별개 (Redis 캐시 옵션) | in-memory TTLCache 선택 (Redis는 SP-1 이후 도입 검토) |
|
||||||
|
|
||||||
|
§4의 SP-A2는 `/api/webai/portfolio`, `/api/webai/news-sentiment`, `/api/stock/screener/run` 3건만 명시. 추가 endpoint 캐시는 out of scope (별도 plan에서).
|
||||||
|
|
||||||
|
### Placeholder 스캔
|
||||||
|
|
||||||
|
- TBD/TODO/"implement later" 패턴 없음 ✓
|
||||||
|
- 모든 code step에 완전 코드 포함 ✓
|
||||||
|
- Task 6에 한 가지 conditional ("`post_run`의 정확한 return 라인을 먼저 확인") — 이건 plan 실행 시 grep 명령으로 즉시 해결 가능한 단순 lookup이라 placeholder가 아님. 그러나 안전성 위해 helper note 그대로 유지.
|
||||||
|
|
||||||
|
### Type consistency
|
||||||
|
|
||||||
|
- `webai_cache.cache_get_portfolio()` / `cache_set_portfolio(value)` — Task 3에서 정의, Task 4에서 사용. 시그니처 일치 ✓
|
||||||
|
- `cache_get_news(date)` — Task 3·5 일치 ✓
|
||||||
|
- `cache_get_screener(mode, top_n, weights)` / `cache_set_screener(mode, top_n, weights, value)` — Task 3·6 일치 ✓
|
||||||
|
- 변수명 `cached`, `result`, `payload` — 각 함수 안에서만 사용, 충돌 없음 ✓
|
||||||
|
|
||||||
|
### 위험·주의
|
||||||
|
|
||||||
|
- **NAS deployer rebuild**: `requirements.txt` 변경은 docker image rebuild 필요. deployer가 변경 감지 시 rebuild 트리거. 만약 deployer가 변경 미감지(예: requirements.txt만 변경 시 rebuild 안 함)라면 NAS에서 `docker compose build stock --no-cache && docker compose up -d stock` 수동 실행 필요.
|
||||||
|
- **Cache stale**: TTL이 충분히 짧아 stale 문제 미미. portfolio 120s = web-ai 폴링 주기(1분) 2배. 변경 감지에 최대 2분 지연.
|
||||||
|
- **Cache miss thunder herd**: V1+V2가 정확히 동시에 캐시 miss 시 KIS 동시 호출 가능. 현재 V1/V2 둘 다 정지 상태라 risk 0. 향후 재시작 시 KIS rate limit 모니터링 필요 (별도 plan 항목).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 후 다음 단계
|
||||||
|
|
||||||
|
Plan-A 완료 후 spec §14 "차후 plan 작성 순서 권장"대로:
|
||||||
|
|
||||||
|
1. **Plan-B-Base** — SP-1 (Redis) + SP-2 (WSL2)
|
||||||
|
2. **Plan-B-Insta** — SP-3 + SP-4
|
||||||
|
3. **Plan-B-Music** — SP-5 + SP-6
|
||||||
|
4. **Plan-B-Video** — SP-7 + SP-8
|
||||||
|
5. **Plan-B-Infra** — SP-9 + SP-10
|
||||||
|
|
||||||
|
각각은 별도 brainstorm 없이 spec §10에서 직접 plan 작성 가능 (이미 명세 충분).
|
||||||
1887
docs/superpowers/plans/2026-05-19-plan-b-insta-render.md
Normal file
1887
docs/superpowers/plans/2026-05-19-plan-b-insta-render.md
Normal file
File diff suppressed because it is too large
Load Diff
3241
docs/superpowers/plans/2026-05-19-plan-b-music-render.md
Normal file
3241
docs/superpowers/plans/2026-05-19-plan-b-music-render.md
Normal file
File diff suppressed because it is too large
Load Diff
2573
docs/superpowers/plans/2026-05-19-plan-b-video-render.md
Normal file
2573
docs/superpowers/plans/2026-05-19-plan-b-video-render.md
Normal file
File diff suppressed because it is too large
Load Diff
1651
docs/superpowers/plans/2026-05-20-lotto-active-agent.md
Normal file
1651
docs/superpowers/plans/2026-05-20-lotto-active-agent.md
Normal file
File diff suppressed because it is too large
Load Diff
1587
docs/superpowers/plans/2026-05-22-lotto-weight-evolver.md
Normal file
1587
docs/superpowers/plans/2026-05-22-lotto-weight-evolver.md
Normal file
File diff suppressed because it is too large
Load Diff
929
docs/superpowers/plans/2026-05-22-plan-b-infra.md
Normal file
929
docs/superpowers/plans/2026-05-22-plan-b-infra.md
Normal file
@@ -0,0 +1,929 @@
|
|||||||
|
# Plan-B-Infra — NSSM 자동 시작 + task-watcher (시간대 큐 토글) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Windows AI 머신의 서비스(ai_trade + WSL2 Docker)를 NSSM으로 부팅 시 자동 시작 + 우선순위 설정(SP-9), 그리고 시간대 기반으로 `queue:paused`를 토글하는 task-watcher 컨테이너 신설(SP-10). 트레이딩 시간대(비휴장 평일 07:00–16:30)에 무거운 render 작업을 일시정지하여 KIS 트레이딩 우선순위 보장.
|
||||||
|
|
||||||
|
**Architecture:** task-watcher는 WSL2 Docker 컨테이너로 30초마다 `current_mode()` 판정(KST 시각 + NAS `/api/stock/holidays` 조회) → 트레이딩 시간대면 `SET queue:paused 1 EX 600`, 그 외엔 `DEL queue:paused`. 모든 render worker(insta/music/video)가 BLPOP 전 `queue:paused`를 확인하므로 단일 키로 전체 일시정지. NSSM(SP-9)은 박재오 Windows 머신에서 수동 설치 — plan은 정확한 명령 + 안내 문서 제공.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12 / `redis>=5.0` / `httpx` (holidays fetch) / `zoneinfo` (KST) / Docker Engine in WSL2 / NSSM (Windows service manager) / FastAPI (NAS stock holidays endpoint)
|
||||||
|
|
||||||
|
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §3 시간대별 우선순위 모드, §10 SP-9·SP-10. **박재오 결정 (2026-05-22): idle/게임 감지 생략 — 시간대만으로 토글** (spec §3의 "박재오 활동 감지 시 SET" → "트레이딩 시간대면 무조건 SET"). idle 감지가 없으므로 WSL2 컨테이너로 구현 가능 (Win32 input API 불필요).
|
||||||
|
|
||||||
|
**Spec 갱신 사항 (현 상태 반영):**
|
||||||
|
- `signal_v2` → **`ai_trade`** (rename 완료, web-ai/ai_trade/)
|
||||||
|
- `Ubuntu-22.04` → **`Ubuntu-24.04`** (Plan-B-Base에서 변경)
|
||||||
|
- `web-ai-services` → **`web-ai/services`** (실제 경로)
|
||||||
|
- `/api/stock/holidays` endpoint **미존재 → 신설** (Task 1)
|
||||||
|
|
||||||
|
**Prerequisites (✅ 모두 완료):**
|
||||||
|
- Plan-A / Plan-B-Base / Plan-B-Insta / Plan-B-Music / Plan-B-Video 모두 완료
|
||||||
|
- WSL2 mirror mode + Redis chown 999:999 영구 적용
|
||||||
|
- services/.env 분기 패턴 정착 (NAS_BASE_URL service-local default)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 구조
|
||||||
|
|
||||||
|
| Phase | 내용 | Task |
|
||||||
|
|-------|------|------|
|
||||||
|
| **1. NAS stock holidays endpoint** | `/api/stock/holidays` GET 신설 (task-watcher가 조회) | 1 |
|
||||||
|
| **2. Windows task-watcher** | mode 판정 + Redis 토글 loop + Dockerfile + compose | 2~6 |
|
||||||
|
| **3. NSSM 안내 + 검증** | SP-9 NSSM 안내 문서 + 박재오 빌드 + end-to-end | 7~8 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Phase 1 — NAS web-backend
|
||||||
|
|
||||||
|
| 파일 | 변경 | 책임 |
|
||||||
|
|------|------|------|
|
||||||
|
| `web-backend/stock/app/main.py` | `GET /api/stock/holidays` endpoint 추가 | holidays.json + 주말 노출 |
|
||||||
|
| `web-backend/stock/app/test_holidays_endpoint.py` (Create) | 2 tests | TDD |
|
||||||
|
|
||||||
|
### Phase 2 — Windows web-ai/services/task-watcher
|
||||||
|
|
||||||
|
| 파일 | 변경 | 책임 |
|
||||||
|
|------|------|------|
|
||||||
|
| `web-ai/services/task-watcher/mode.py` (Create) | `current_mode(now, holidays)` 순수 함수 + `fetch_holidays()` | 시간대 판정 |
|
||||||
|
| `web-ai/services/task-watcher/watcher.py` (Create) | 30초 loop + Redis 토글 | dispatcher |
|
||||||
|
| `web-ai/services/task-watcher/main.py` (Create) | FastAPI + lifespan(watcher spawn) + /health | entry |
|
||||||
|
| `web-ai/services/task-watcher/Dockerfile` (Create) | python:3.12-slim | image |
|
||||||
|
| `web-ai/services/task-watcher/requirements.txt` (Create) | fastapi, redis, httpx, pytest | deps |
|
||||||
|
| `web-ai/services/task-watcher/.env.example` (Create) | REDIS_URL, STOCK_BASE_URL, TRADING_START, TRADING_END | secrets |
|
||||||
|
| `web-ai/services/task-watcher/tests/test_mode.py` (Create) | current_mode 6 cases | TDD |
|
||||||
|
| `web-ai/services/task-watcher/tests/__init__.py` (Create) | 빈 marker | pkg |
|
||||||
|
| `web-ai/services/docker-compose.yml` | task-watcher service 추가 (port 18713) | compose |
|
||||||
|
|
||||||
|
### Phase 3 — 안내 문서
|
||||||
|
|
||||||
|
| 파일 | 변경 | 책임 |
|
||||||
|
|------|------|------|
|
||||||
|
| `web-ai/services/task-watcher/NSSM_SETUP.md` (Create) | SP-9 NSSM 설치 안내 (ai_trade + wsl_docker + task-watcher) | 박재오 수동 가이드 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: NAS stock — `/api/stock/holidays` endpoint + tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py`
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py`
|
||||||
|
|
||||||
|
### Step 1: 실패 테스트 작성
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""GET /api/stock/holidays — task-watcher 휴장일 조회용."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_holidays_returns_list():
|
||||||
|
r = client.get("/api/stock/holidays")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert "holidays" in data
|
||||||
|
assert isinstance(data["holidays"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_holidays_entries_are_iso_dates():
|
||||||
|
r = client.get("/api/stock/holidays")
|
||||||
|
holidays = r.json()["holidays"]
|
||||||
|
# 비어 있지 않다면 ISO date 형식 (YYYY-MM-DD)
|
||||||
|
if holidays:
|
||||||
|
import datetime as dt
|
||||||
|
for h in holidays[:5]:
|
||||||
|
dt.date.fromisoformat(h) # raise 안 하면 통과
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 테스트 실패 확인
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_holidays_endpoint.py -v`
|
||||||
|
Expected: FAIL — endpoint 404.
|
||||||
|
|
||||||
|
### Step 3: `main.py`에 endpoint 추가
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py`에서 `_HOLIDAYS_PATH` (현재 line 82 부근) 정의를 활용. 적절한 위치(다른 `@app.get` 근처)에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/api/stock/holidays")
|
||||||
|
def get_holidays():
|
||||||
|
"""task-watcher가 조회하는 휴장일 목록. holidays.json 그대로 노출 (인증 불필요)."""
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
with open(_HOLIDAYS_PATH, encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
# holidays.json 구조가 list이거나 {"holidays": [...]} 또는 {year: [...]} 형태일 수 있음
|
||||||
|
if isinstance(data, list):
|
||||||
|
holidays = data
|
||||||
|
elif isinstance(data, dict) and "holidays" in data:
|
||||||
|
holidays = data["holidays"]
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
# {year: [dates]} → flatten
|
||||||
|
holidays = [d for v in data.values() if isinstance(v, list) for d in v]
|
||||||
|
else:
|
||||||
|
holidays = []
|
||||||
|
except (OSError, ValueError):
|
||||||
|
holidays = []
|
||||||
|
return {"holidays": holidays}
|
||||||
|
```
|
||||||
|
|
||||||
|
**주의:** 작성 전 `holidays.json` 실제 구조를 확인할 것 (`Read web-backend/stock/app/holidays.json`). 위 코드는 list / `{"holidays":[]}` / `{year:[]}` 3가지 형태를 모두 처리하지만, 실제 구조에 맞게 단순화 가능.
|
||||||
|
|
||||||
|
### Step 4: 테스트 통과
|
||||||
|
|
||||||
|
Run: `python -m pytest app/test_holidays_endpoint.py -v`
|
||||||
|
Expected: 2 PASS.
|
||||||
|
|
||||||
|
### Step 5: 회귀 확인
|
||||||
|
|
||||||
|
Run: `python -m pytest app/ -v 2>&1 | tail -5`
|
||||||
|
Expected: 기존 stock 테스트 모두 통과 + 새 2개.
|
||||||
|
|
||||||
|
### Step 6: 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
||||||
|
git add stock/app/main.py stock/app/test_holidays_endpoint.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(stock): GET /api/stock/holidays endpoint (SP-10 task-watcher용)
|
||||||
|
|
||||||
|
holidays.json 노출. task-watcher가 휴장일 판정에 조회.
|
||||||
|
인증 불필요 (민감 정보 아님). 주말은 task-watcher가 weekday로 별도 판정.
|
||||||
|
Plan-B-Infra Phase 1.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- spec §3: "휴장일 단일 소스 — web-backend/stock/app/holidays.json 정본. NAS stock이 GET /api/stock/holidays로 노출."
|
||||||
|
- 현재 holidays.json은 `_is_holiday()` 내부 함수에서만 사용, HTTP endpoint 없음 → 신설.
|
||||||
|
- stock 컨테이너는 이미 deploy.sh BUILD_TARGETS에 등재됨 (신규 lab 아님 — deploy scripts 추가 불필요).
|
||||||
|
- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-backend`
|
||||||
|
|
||||||
|
## Report
|
||||||
|
|
||||||
|
- Status: DONE | DONE_WITH_CONCERNS | BLOCKED
|
||||||
|
- holidays.json 실제 구조 (확인 결과)
|
||||||
|
- 2 PASS + 회귀
|
||||||
|
- 커밋 SHA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Windows task-watcher — mode.py (current_mode + fetch_holidays) + tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py`
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/__init__.py`
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py`
|
||||||
|
|
||||||
|
### Step 1: 실패 테스트 작성
|
||||||
|
|
||||||
|
`tests/__init__.py`: (빈 파일)
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""current_mode — 시간대 + 휴장일 판정 (순수 함수)."""
|
||||||
|
import datetime as dt
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from mode import current_mode
|
||||||
|
|
||||||
|
KST = ZoneInfo("Asia/Seoul")
|
||||||
|
HOLIDAYS = {"2026-05-25"} # 가상 휴장일 (월요일)
|
||||||
|
|
||||||
|
|
||||||
|
def _kst(y, m, d, hh, mm):
|
||||||
|
return dt.datetime(y, m, d, hh, mm, tzinfo=KST)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekday_trading_hours_is_trading():
|
||||||
|
# 2026-05-22 금요일 10:00 — 트레이딩 시간대
|
||||||
|
assert current_mode(_kst(2026, 5, 22, 10, 0), HOLIDAYS) == "trading"
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekday_before_open_is_free():
|
||||||
|
# 평일 06:00 — 장 전
|
||||||
|
assert current_mode(_kst(2026, 5, 22, 6, 0), HOLIDAYS) == "free"
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekday_after_close_is_free():
|
||||||
|
# 평일 17:00 — 장 마감 후
|
||||||
|
assert current_mode(_kst(2026, 5, 22, 17, 0), HOLIDAYS) == "free"
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekend_is_free():
|
||||||
|
# 2026-05-23 토요일 10:00
|
||||||
|
assert current_mode(_kst(2026, 5, 23, 10, 0), HOLIDAYS) == "free"
|
||||||
|
|
||||||
|
|
||||||
|
def test_holiday_weekday_is_free():
|
||||||
|
# 2026-05-25 월요일이지만 휴장일 → 트레이딩 시간대라도 free
|
||||||
|
assert current_mode(_kst(2026, 5, 25, 10, 0), HOLIDAYS) == "free"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trading_boundary_inclusive_start_exclusive_end():
|
||||||
|
# 07:00 정각 = 트레이딩 시작, 16:30 정각 = 마감 (16:30은 free)
|
||||||
|
assert current_mode(_kst(2026, 5, 22, 7, 0), HOLIDAYS) == "trading"
|
||||||
|
assert current_mode(_kst(2026, 5, 22, 16, 29), HOLIDAYS) == "trading"
|
||||||
|
assert current_mode(_kst(2026, 5, 22, 16, 30), HOLIDAYS) == "free"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 테스트 실패 확인
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -m pytest tests/test_mode.py -v`
|
||||||
|
Expected: FAIL — `mode` 모듈 미존재.
|
||||||
|
|
||||||
|
### Step 3: `mode.py` 작성
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""시간대 + 휴장일 기반 모드 판정 (idle 감지 생략 — 박재오 결정 2026-05-22).
|
||||||
|
|
||||||
|
trading: 비휴장 평일 07:00–16:30 (장중) → queue:paused SET
|
||||||
|
free: 그 외 (장 전/후, 주말, 휴장) → queue:paused DEL
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Set
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KST = ZoneInfo("Asia/Seoul")
|
||||||
|
STOCK_BASE_URL = os.getenv("STOCK_BASE_URL", "http://192.168.45.54:18500")
|
||||||
|
|
||||||
|
# 트레이딩 윈도우 (HH:MM, KST). .env로 조정 가능.
|
||||||
|
TRADING_START = os.getenv("TRADING_START", "07:00")
|
||||||
|
TRADING_END = os.getenv("TRADING_END", "16:30")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hhmm(s: str) -> dt.time:
|
||||||
|
hh, mm = s.split(":")
|
||||||
|
return dt.time(int(hh), int(mm))
|
||||||
|
|
||||||
|
|
||||||
|
def current_mode(now: dt.datetime, holidays: Set[str]) -> str:
|
||||||
|
"""now(KST aware) + holidays(ISO date set) → 'trading' | 'free'."""
|
||||||
|
# 주말 (토=5, 일=6)
|
||||||
|
if now.weekday() >= 5:
|
||||||
|
return "free"
|
||||||
|
# 휴장일
|
||||||
|
if now.date().isoformat() in holidays:
|
||||||
|
return "free"
|
||||||
|
# 트레이딩 윈도우 [start, end)
|
||||||
|
start = _parse_hhmm(TRADING_START)
|
||||||
|
end = _parse_hhmm(TRADING_END)
|
||||||
|
t = now.timetz().replace(tzinfo=None)
|
||||||
|
if start <= t < end:
|
||||||
|
return "trading"
|
||||||
|
return "free"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_holidays() -> Set[str]:
|
||||||
|
"""NAS stock /api/stock/holidays 조회. 실패 시 빈 set (안전 — free로 판정)."""
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{STOCK_BASE_URL}/api/stock/holidays", timeout=10.0)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return set(r.json().get("holidays", []))
|
||||||
|
logger.warning("holidays fetch returned %d", r.status_code)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("holidays fetch 실패")
|
||||||
|
return set()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: 테스트 통과
|
||||||
|
|
||||||
|
Run: `python -m pytest tests/test_mode.py -v`
|
||||||
|
Expected: 6 PASS.
|
||||||
|
|
||||||
|
### Step 5: 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git add services/task-watcher/mode.py services/task-watcher/tests/__init__.py services/task-watcher/tests/test_mode.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(task-watcher): mode.py — 시간대+휴장일 판정 (SP-10)
|
||||||
|
|
||||||
|
current_mode(now, holidays): 비휴장 평일 07:00–16:30 → trading, 그 외 free.
|
||||||
|
fetch_holidays(): NAS /api/stock/holidays 조회 (실패 시 빈 set = free 안전).
|
||||||
|
TRADING_START/END env로 윈도우 조정. idle 감지 생략 (박재오 결정).
|
||||||
|
6 tests (평일 장중/장전/장후, 주말, 휴장, 경계).
|
||||||
|
Plan-B-Infra Phase 2.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- KST 시각 + holidays set → trading/free 순수 함수. 테스트 용이 (now를 인자로).
|
||||||
|
- holidays는 fetch_holidays()로 NAS 조회. 매 loop마다 호출하면 부하 — watcher.py에서 캐싱 (Task 3).
|
||||||
|
- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-ai`
|
||||||
|
|
||||||
|
## Report
|
||||||
|
- Status / 6 PASS / 커밋 SHA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Windows task-watcher — watcher.py (Redis 토글 loop)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py`
|
||||||
|
|
||||||
|
### Step 1: `watcher.py` 작성
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""30초마다 current_mode 판정 → queue:paused 토글.
|
||||||
|
|
||||||
|
trading → SET queue:paused 1 EX 600 (10분 TTL — watcher 죽어도 자동 해제)
|
||||||
|
free → DEL queue:paused
|
||||||
|
holidays는 1시간마다 refresh (매 loop fetch 부하 회피).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from mode import current_mode, fetch_holidays, KST
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
|
||||||
|
PAUSED_KEY = "queue:paused"
|
||||||
|
LOOP_INTERVAL = 30 # 초
|
||||||
|
HOLIDAYS_REFRESH = 3600 # 1시간
|
||||||
|
PAUSED_TTL = 600 # 10분 (watcher 죽어도 자동 해제)
|
||||||
|
|
||||||
|
|
||||||
|
async def watcher_loop():
|
||||||
|
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||||
|
holidays = fetch_holidays()
|
||||||
|
last_holiday_refresh = dt.datetime.now(KST)
|
||||||
|
last_mode = None
|
||||||
|
logger.info("task-watcher started (trading window 토글)")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
now = dt.datetime.now(KST)
|
||||||
|
# holidays 주기적 refresh
|
||||||
|
if (now - last_holiday_refresh).total_seconds() >= HOLIDAYS_REFRESH:
|
||||||
|
holidays = fetch_holidays()
|
||||||
|
last_holiday_refresh = now
|
||||||
|
|
||||||
|
mode = current_mode(now, holidays)
|
||||||
|
if mode == "trading":
|
||||||
|
await redis.set(PAUSED_KEY, b"1", ex=PAUSED_TTL)
|
||||||
|
else:
|
||||||
|
await redis.delete(PAUSED_KEY)
|
||||||
|
|
||||||
|
if mode != last_mode:
|
||||||
|
logger.info("mode 전환: %s → %s (paused=%s)", last_mode, mode, mode == "trading")
|
||||||
|
last_mode = mode
|
||||||
|
|
||||||
|
await asyncio.sleep(LOOP_INTERVAL)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("watcher_loop cancelled")
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("watcher_loop iteration 실패, 30초 후 재시도")
|
||||||
|
await asyncio.sleep(LOOP_INTERVAL)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 임포트 smoke
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -c "from watcher import watcher_loop; print('OK')"`
|
||||||
|
Expected: `OK`.
|
||||||
|
|
||||||
|
### Step 3: 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git add services/task-watcher/watcher.py
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(task-watcher): watcher.py — 30초 loop + queue:paused 토글 (SP-10)
|
||||||
|
|
||||||
|
trading → SET queue:paused 1 EX 600 / free → DEL.
|
||||||
|
holidays 1시간마다 refresh. PAUSED_TTL 600s (watcher 죽어도 자동 해제 — 안전).
|
||||||
|
mode 전환 시에만 로그.
|
||||||
|
Plan-B-Infra Phase 2.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `PAUSED_TTL=600`이 핵심 안전장치: task-watcher가 죽어도 10분 후 자동으로 paused 해제 → 큐 영구 정지 방지.
|
||||||
|
- holidays는 1시간 캐싱 (매 30초 fetch 안 함).
|
||||||
|
- render worker들(insta/music/video)이 이미 `queue:paused` 체크 로직 보유 (Plan-B-Insta/Music/Video).
|
||||||
|
|
||||||
|
## Report
|
||||||
|
- Status / smoke 결과 / 커밋 SHA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Windows task-watcher — main.py + Dockerfile + requirements + .env.example
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/main.py`
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/Dockerfile`
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/requirements.txt`
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/.env.example`
|
||||||
|
|
||||||
|
### Step 1: `requirements.txt`
|
||||||
|
|
||||||
|
```
|
||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
redis>=5.0
|
||||||
|
httpx>=0.27
|
||||||
|
pytest>=8.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: `Dockerfile`
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
|
```
|
||||||
|
|
||||||
|
(tzdata 추가 — zoneinfo Asia/Seoul 사용.)
|
||||||
|
|
||||||
|
### Step 3: `.env.example`
|
||||||
|
|
||||||
|
```
|
||||||
|
# Plan-B-Infra — task-watcher
|
||||||
|
|
||||||
|
# NAS Redis
|
||||||
|
REDIS_URL=redis://192.168.45.54:6379
|
||||||
|
|
||||||
|
# NAS stock holidays endpoint
|
||||||
|
STOCK_BASE_URL=http://192.168.45.54:18500
|
||||||
|
|
||||||
|
# 트레이딩 윈도우 (KST, HH:MM) — 이 시간대에만 queue:paused
|
||||||
|
TRADING_START=07:00
|
||||||
|
TRADING_END=16:30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: `main.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""task-watcher FastAPI entry — health + lifespan (watcher loop spawn)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
import watcher
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
watcher_task = asyncio.create_task(watcher.watcher_loop())
|
||||||
|
logger.info("task-watcher lifespan 시작")
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
watcher_task.cancel()
|
||||||
|
try:
|
||||||
|
await watcher_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("task-watcher lifespan 종료")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "service": "task-watcher"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: smoke + 회귀
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher
|
||||||
|
python -c "from main import app; print(len(app.routes))"
|
||||||
|
python -m pytest tests/ -v 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
Expected: 숫자 출력 + 6 PASS (test_mode).
|
||||||
|
|
||||||
|
### Step 6: 커밋
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git add services/task-watcher/main.py services/task-watcher/Dockerfile services/task-watcher/requirements.txt services/task-watcher/.env.example
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(task-watcher): main.py + Dockerfile + requirements + env (SP-10)
|
||||||
|
|
||||||
|
FastAPI lifespan에서 watcher_loop 스폰. /health. tzdata(zoneinfo Asia/Seoul).
|
||||||
|
.env: REDIS_URL, STOCK_BASE_URL, TRADING_START/END.
|
||||||
|
Plan-B-Infra Phase 2.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Report
|
||||||
|
- Status / routes 개수 / 6 PASS / 커밋 SHA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Windows services/docker-compose — task-watcher entry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`
|
||||||
|
|
||||||
|
### Step 1: video-render service 다음에 task-watcher 추가
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`에 추가:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
|
||||||
|
task-watcher:
|
||||||
|
build:
|
||||||
|
context: ./task-watcher
|
||||||
|
container_name: task-watcher
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18713:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
||||||
|
- STOCK_BASE_URL=${STOCK_BASE_URL:-http://192.168.45.54:18500}
|
||||||
|
- TRADING_START=${TRADING_START:-07:00}
|
||||||
|
- TRADING_END=${TRADING_END:-16:30}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: YAML 검증
|
||||||
|
|
||||||
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services && python -c "import yaml; yaml.safe_load(open('docker-compose.yml')); print('valid YAML')"`
|
||||||
|
Expected: `valid YAML`.
|
||||||
|
|
||||||
|
### Step 3: 커밋 + push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git add services/docker-compose.yml
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
feat(task-watcher): services/docker-compose entry (SP-10)
|
||||||
|
|
||||||
|
port 18713, REDIS_URL/STOCK_BASE_URL/TRADING_START/END env.
|
||||||
|
insta/music/video-render와 같은 services 묶음. outbound only.
|
||||||
|
Plan-B-Infra Phase 2 완료 — 박재오 빌드 대기.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
git push 2>&1 # 자격증명 실패 시 박재오 수동 push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Report
|
||||||
|
- Status / YAML 검증 / 커밋 SHA / push 결과
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: NSSM 안내 문서 (SP-9)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md`
|
||||||
|
|
||||||
|
SP-9는 박재오 Windows 머신에서 NSSM 수동 설치. controller는 정확한 명령 + 안내 문서 작성. (코드 아님 — 안내 문서.)
|
||||||
|
|
||||||
|
### Step 1: `NSSM_SETUP.md` 작성
|
||||||
|
|
||||||
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# NSSM 자동 시작 설정 (SP-9)
|
||||||
|
|
||||||
|
Windows AI 머신 부팅 시 ai_trade(트레이딩) + WSL2 Docker(render workers + task-watcher) 자동 시작.
|
||||||
|
|
||||||
|
## 1. NSSM 다운로드
|
||||||
|
|
||||||
|
https://nssm.cc/download → nssm-2.24.zip → `C:\nssm\nssm.exe` 배치 (또는 PATH 등록).
|
||||||
|
|
||||||
|
## 2. ai_trade (Native Python, HIGH priority)
|
||||||
|
|
||||||
|
⚠️ spec의 signal_v2는 ai_trade로 rename됨. 경로/포트 확인.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 관리자 PowerShell
|
||||||
|
C:\nssm\nssm.exe install ai_trade "C:\Python312\python.exe" "-m uvicorn main:app --host 0.0.0.0 --port 8001"
|
||||||
|
C:\nssm\nssm.exe set ai_trade AppDirectory "C:\Users\jaeoh\Desktop\workspace\web-ai\ai_trade"
|
||||||
|
C:\nssm\nssm.exe set ai_trade Priority HIGH_PRIORITY_CLASS
|
||||||
|
C:\nssm\nssm.exe set ai_trade Start SERVICE_AUTO_START
|
||||||
|
C:\nssm\nssm.exe set ai_trade AppStdout "C:\Users\jaeoh\nssm-logs\ai_trade.log"
|
||||||
|
C:\nssm\nssm.exe set ai_trade AppStderr "C:\Users\jaeoh\nssm-logs\ai_trade.log"
|
||||||
|
```
|
||||||
|
|
||||||
|
(ai_trade의 실제 진입점이 main:app + port 8001인지 확인. 다르면 조정.)
|
||||||
|
|
||||||
|
## 3. WSL2 Docker (NORMAL priority — render workers + task-watcher)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
C:\nssm\nssm.exe install wsl_docker "C:\Windows\System32\wsl.exe" "-d Ubuntu-24.04 -- sh -c 'sudo service docker start && cd /workspace/web-ai/services && docker compose up -d'"
|
||||||
|
C:\nssm\nssm.exe set wsl_docker Priority NORMAL_PRIORITY_CLASS
|
||||||
|
C:\nssm\nssm.exe set wsl_docker Start SERVICE_AUTO_START
|
||||||
|
C:\nssm\nssm.exe set wsl_docker AppStdout "C:\Users\jaeoh\nssm-logs\wsl_docker.log"
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ 변경점: Ubuntu-22.04 → **Ubuntu-24.04**, web-ai-services → **web-ai/services**. WSL 경로는 `/mnt/c/...` 또는 박재오 WSL 마운트 기준 (`/workspace`가 web-ai에 매핑되어 있으면 그대로).
|
||||||
|
|
||||||
|
`sudo service docker start`가 비밀번호 요구하면 sudoers에 NOPASSWD 추가:
|
||||||
|
```bash
|
||||||
|
# WSL2 안
|
||||||
|
echo "$USER ALL=(ALL) NOPASSWD: /usr/sbin/service docker start" | sudo tee /etc/sudoers.d/docker-start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 서비스 시작 + 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
C:\nssm\nssm.exe start ai_trade
|
||||||
|
C:\nssm\nssm.exe start wsl_docker
|
||||||
|
|
||||||
|
# 상태 확인
|
||||||
|
C:\nssm\nssm.exe status ai_trade
|
||||||
|
C:\nssm\nssm.exe status wsl_docker
|
||||||
|
sc query ai_trade
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 검증
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# ai_trade
|
||||||
|
curl http://localhost:8001/health # 또는 ai_trade의 실제 health endpoint
|
||||||
|
|
||||||
|
# WSL2 docker 컨테이너 (재부팅 후 자동 시작 확인)
|
||||||
|
wsl -d Ubuntu-24.04 -- docker ps
|
||||||
|
# insta-render, music-render, video-render, task-watcher 4개 Up 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 재부팅 테스트
|
||||||
|
|
||||||
|
Windows 재부팅 → 로그인 → 수동 조작 없이:
|
||||||
|
- ai_trade 서비스 자동 시작 (HIGH priority)
|
||||||
|
- WSL2 + Docker + 4 컨테이너 자동 시작 (NORMAL priority)
|
||||||
|
- task-watcher가 trading window에 queue:paused 토글 시작
|
||||||
|
|
||||||
|
## task-watcher 동작 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# WSL2
|
||||||
|
docker logs task-watcher --tail 20
|
||||||
|
# 기대: "task-watcher started" + mode 전환 로그 (trading/free)
|
||||||
|
|
||||||
|
# Redis 큐 상태 (NAS 또는 LAN)
|
||||||
|
docker exec redis redis-cli GET queue:paused
|
||||||
|
# 트레이딩 시간대(평일 07:00-16:30): "1"
|
||||||
|
# 그 외: (nil)
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 커밋 + push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
||||||
|
git add services/task-watcher/NSSM_SETUP.md
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
docs(task-watcher): NSSM_SETUP.md — SP-9 자동 시작 안내
|
||||||
|
|
||||||
|
ai_trade(HIGH, native python :8001) + wsl_docker(NORMAL, WSL2 Ubuntu-24.04
|
||||||
|
docker compose up). spec의 signal_v2→ai_trade, 22.04→24.04, web-ai-services
|
||||||
|
→web-ai/services 정정. sudoers NOPASSWD + 재부팅 검증 절차.
|
||||||
|
Plan-B-Infra Phase 3.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
git push 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Report
|
||||||
|
- Status / 커밋 SHA / push 결과
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 박재오 빌드 + task-watcher 검증
|
||||||
|
|
||||||
|
**Files:** (변경 없음 — 박재오 측 작업 + 검증)
|
||||||
|
|
||||||
|
### Step 1: web-backend push (Task 1 holidays endpoint)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git push
|
||||||
|
```
|
||||||
|
→ NAS deployer가 stock 컨테이너 rebuild. `/api/stock/holidays` 활성화.
|
||||||
|
|
||||||
|
### Step 2: 박재오 NAS 측 holidays endpoint 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://gahusb.synology.me/api/stock/holidays
|
||||||
|
# → {"holidays": ["2026-01-01", ...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: 박재오 Windows 측 task-watcher 빌드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /workspace/web-ai && git pull
|
||||||
|
cd /workspace/web-ai/services
|
||||||
|
docker compose build task-watcher
|
||||||
|
docker compose up -d task-watcher
|
||||||
|
docker logs task-watcher --tail 20
|
||||||
|
# 기대: "task-watcher lifespan 시작" + "task-watcher started" + mode 로그
|
||||||
|
curl -m 3 http://localhost:18713/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: 시간대 토글 검증
|
||||||
|
|
||||||
|
현재 KST 시각 기준:
|
||||||
|
```bash
|
||||||
|
# 트레이딩 시간대(평일 07:00-16:30)면 paused=1, 아니면 nil
|
||||||
|
docker exec task-watcher python -c "import datetime as dt; from zoneinfo import ZoneInfo; from mode import current_mode, fetch_holidays; print('now mode:', current_mode(dt.datetime.now(ZoneInfo('Asia/Seoul')), fetch_holidays()))"
|
||||||
|
|
||||||
|
# Redis 확인 (NAS 또는 LAN)
|
||||||
|
ssh nas
|
||||||
|
docker exec redis redis-cli GET queue:paused
|
||||||
|
```
|
||||||
|
|
||||||
|
기대:
|
||||||
|
- 평일 07:00-16:30 (비휴장): `current_mode` = "trading", `queue:paused` = "1"
|
||||||
|
- 그 외: "free", (nil)
|
||||||
|
|
||||||
|
### Step 5: render worker가 paused 존중하는지 (선택)
|
||||||
|
|
||||||
|
트레이딩 시간대에 video 생성 요청 → worker가 BLPOP 전 paused 확인 → 10초 대기 반복 (처리 보류). free 시간대 되면 자동 처리. (이미 Plan-B-Insta/Music/Video worker에 `queue:paused` 체크 로직 있음.)
|
||||||
|
|
||||||
|
### Step 6: 메모리 기록
|
||||||
|
|
||||||
|
`reference_plan_b_infra_complete.md` 작성 + MEMORY.md 인덱스 추가 (Task 8에서).
|
||||||
|
|
||||||
|
## Report
|
||||||
|
- holidays endpoint 응답
|
||||||
|
- task-watcher health + mode
|
||||||
|
- queue:paused 토글 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: 메모리 기록 + 최종 정리
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/reference_plan_b_infra_complete.md`
|
||||||
|
- Modify: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/MEMORY.md`
|
||||||
|
|
||||||
|
### Step 1: `reference_plan_b_infra_complete.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: plan-b-infra-complete
|
||||||
|
description: 2026-05-22 Plan-B-Infra — NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). spec 12 SP 전부 완료
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
Plan-B-Infra 2026-05-22 완료. spec §10 SP-9 + SP-10. 이로써 NAS↔Windows 분산 아키텍처 spec의 12 SP 전부 완료.
|
||||||
|
|
||||||
|
## SP-10 task-watcher (구현)
|
||||||
|
- web-ai/services/task-watcher/ WSL2 컨테이너 (port 18713)
|
||||||
|
- 30초 loop: current_mode(KST + holidays) → queue:paused 토글
|
||||||
|
- trading(비휴장 평일 07:00-16:30) → SET queue:paused 1 EX 600 / free → DEL
|
||||||
|
- **idle/게임 감지 생략** (박재오 결정 2026-05-22) — WSL2 컨테이너는 Win32 input API 접근 불가. 시간대만으로 판정.
|
||||||
|
- PAUSED_TTL 600s = watcher 죽어도 10분 후 자동 해제 (큐 영구정지 방지 안전장치)
|
||||||
|
- holidays는 NAS GET /api/stock/holidays (신설) 1시간 캐싱
|
||||||
|
- TRADING_START/END env로 윈도우 조정
|
||||||
|
|
||||||
|
## SP-9 NSSM (박재오 수동)
|
||||||
|
- NSSM_SETUP.md 안내 문서. ai_trade(HIGH, native :8001) + wsl_docker(NORMAL, WSL2 docker compose up)
|
||||||
|
- spec 정정: signal_v2→ai_trade, Ubuntu-22.04→24.04, web-ai-services→web-ai/services
|
||||||
|
|
||||||
|
## NAS holidays endpoint (신설)
|
||||||
|
- GET /api/stock/holidays — holidays.json 노출. 기존엔 _is_holiday() 내부 함수만 있었음.
|
||||||
|
|
||||||
|
## 다음
|
||||||
|
- frontend video/music/insta UI (backend gateway만 완료, UI 별도)
|
||||||
|
- FOLLOW-UP B: -lab suffix 제거
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: MEMORY.md 인덱스 추가
|
||||||
|
|
||||||
|
`reference_plan_b_video_complete.md` 항목 뒤:
|
||||||
|
```markdown
|
||||||
|
- [Plan-B-Infra 완료](reference_plan_b_infra_complete.md) — 2026-05-22 NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). idle 감지 생략. spec 12 SP 전부 완료
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: 양쪽 push 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git status && git log --oneline -3
|
||||||
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai && git status && git log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: 박재오 보고
|
||||||
|
- spec 12 SP 전부 완료
|
||||||
|
- task-watcher 시간대 토글 동작
|
||||||
|
- NSSM은 박재오 수동 (NSSM_SETUP.md 참고)
|
||||||
|
|
||||||
|
## Report
|
||||||
|
- 메모리 파일 생성
|
||||||
|
- push 상태
|
||||||
|
- 최종 보고
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**1. Spec coverage**
|
||||||
|
|
||||||
|
| Spec 요구사항 | 구현 위치 | 상태 |
|
||||||
|
|--------------|-----------|------|
|
||||||
|
| SP-9 §10: NSSM ai_trade(HIGH) + wsl_docker(NORMAL) 자동 시작 | Task 6 NSSM_SETUP.md | ✓ (박재오 수동 + 안내) |
|
||||||
|
| SP-10 §10: task-watcher 컨테이너 30초 loop | Task 3 watcher.py | ✓ |
|
||||||
|
| SP-10 §10: current_mode (시간대 + holidays + KST) | Task 2 mode.py | ✓ |
|
||||||
|
| SP-10 §10: queue:paused 토글 (free→DEL, trading→SET) | Task 3 | ✓ |
|
||||||
|
| §3 휴장일 단일 소스 GET /api/stock/holidays | Task 1 | ✓ (신설) |
|
||||||
|
| 박재오 결정: idle 감지 생략 — 시간대만 | Task 2 (is_user_active 제거) | ✓ |
|
||||||
|
| §3 트레이딩 모드 = 평일 비휴장 07:00-16:30 | Task 2 TRADING_START/END | ✓ |
|
||||||
|
|
||||||
|
**spec 대비 의도적 변경 (박재오 승인):**
|
||||||
|
- idle/게임 감지 생략 — spec §10 SP-10의 `is_user_active()` 제거. trading 시간대면 무조건 paused.
|
||||||
|
- spec §3의 🟡 일반(16:30-23:30) 모드 → free로 통합 (트레이딩 시간대만 paused).
|
||||||
|
|
||||||
|
**2. Placeholder scan:** 통과. NSSM_SETUP.md의 "(확인)" 표기는 박재오 환경 검증 안내 (placeholder 아님).
|
||||||
|
|
||||||
|
**3. Type consistency:**
|
||||||
|
- `current_mode(now: dt.datetime, holidays: Set[str]) -> str` — Task 2 정의, Task 3 watcher_loop + Task 7 검증 호출 일관
|
||||||
|
- `fetch_holidays() -> Set[str]` — Task 2 정의, Task 3 호출
|
||||||
|
- mode 값 `"trading"` | `"free"` 2개 — Task 2/3/7 일관
|
||||||
|
- `PAUSED_KEY = "queue:paused"` — Task 3, render workers의 PAUSED_KEY와 동일 문자열 (Plan-B-Insta/Music/Video)
|
||||||
|
|
||||||
|
**4. 함정 사전 인지:**
|
||||||
|
- task-watcher는 services/ 컨테이너 (NAS lab 아님) → deploy.sh 6위치 등재 불필요
|
||||||
|
- holidays endpoint(stock)는 기존 컨테이너 수정 → deploy.sh 등재 이미 됨
|
||||||
|
- services/.env: TRADING_START/END는 task-watcher 전용 → 다른 서비스와 충돌 없음 (compose default로 분기)
|
||||||
|
- PAUSED_TTL로 watcher 장애 시 큐 영구정지 방지
|
||||||
|
|
||||||
|
플랜 완성. 모든 검토 통과.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록 — 알려진 결정 + follow-up
|
||||||
|
|
||||||
|
**박재오 결정 (2026-05-22):** idle/게임 감지 생략. 시간대만으로 큐 토글. 박재오 7결정 #1의 "Windows 작업 감지 큐 정지"는 부분 포기 (시간대 기반만). 향후 idle 감지 필요 시 Windows native idle-reporter(GetLastInputInfo) → Redis user:last_input_ts 기록 → task-watcher가 읽는 hybrid로 확장 가능.
|
||||||
|
|
||||||
|
**spec 12 SP 완료 후 follow-up:**
|
||||||
|
- frontend `/video` `/music` UI (backend gateway만 완료)
|
||||||
|
- FOLLOW-UP B: `-lab` suffix 일괄 제거
|
||||||
|
- GCS lifecycle (Veo Vertex 미사용으로 무관 — Gemini API는 GCS 안 씀)
|
||||||
|
- Sora 2 alternative (2026-09-24 deprecated 대비)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user