Compare commits
602 Commits
v0.1.0
...
078c9f008a
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 14236f355a | |||
| f1e72e2829 | |||
| 868020f7ed | |||
| f1eab292a2 | |||
| 732d78becc | |||
| 2ce118baba | |||
| 05e7ffdfd9 | |||
| c7401c5d9f | |||
| 5d6fe2f04b | |||
| 2926770d6f | |||
| 197d451d5f | |||
| f45041d46c | |||
| 483963b463 | |||
| 11423e5106 | |||
| de7468b256 | |||
| d6d2eb0787 | |||
| 136aea8aee | |||
| ea9eb749aa | |||
| 71d9d7a571 | |||
| c96815c2e3 | |||
| 4035432c54 | |||
| d28c291a55 | |||
| 21a8173963 | |||
| f6fcff0faf | |||
| 55863d7744 | |||
| a330a5271c | |||
| e27fbfada1 | |||
| 7fb55a7be7 | |||
| 9a8df4908a | |||
| a8cbef75db | |||
| b6fd444dba | |||
| f2e23c1241 | |||
| c6850da4ac | |||
| 8283dab0de | |||
| 9faa1c5715 | |||
| 0e2d241e18 | |||
| 84c5877207 | |||
| cbafc1f959 | |||
| dce6b3e692 | |||
| 3d0dd24f27 | |||
| 2fafce0327 | |||
| 25ede4f478 | |||
| 2493bc72fb | |||
| dd6435eb86 | |||
| 94db1da045 | |||
| d8e4e0461c | |||
| 421e52b205 | |||
| 526d6a53e5 | |||
| 432840a38d | |||
| 597353e6d4 | |||
| bd43c99221 | |||
| 2c95fe49f3 | |||
| 8ccfc32749 | |||
| 67ef3c4bbf | |||
| ee54458bf0 | |||
| e1c3168d5c | |||
| 2d5972c25d | |||
| 1ddbd4ad0e | |||
| f75bf5d3e5 | |||
| 0fde916120 | |||
| 64c526488a | |||
| c655b655c9 | |||
| 005c0261c2 | |||
| 879bb2f25d | |||
| 82cbae7ae2 | |||
| a8b661b304 | |||
| b815c37064 |
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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
136
.env.example
136
.env.example
@@ -1,17 +1,129 @@
|
||||
# timezone
|
||||
# ---------------------------------------------------------------------------
|
||||
# [Environment Configuration]
|
||||
# 이 파일을 복사하여 .env 파일을 생성하고, 환경에 맞게 주석을 해제/수정하여 사용하세요.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# [COMMON]
|
||||
APP_VERSION=dev
|
||||
TZ=Asia/Seoul
|
||||
|
||||
COMPOSE_PROJECT_NAME=webpage
|
||||
|
||||
# backend lotto collector sources
|
||||
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
|
||||
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
|
||||
|
||||
# travel-proxy
|
||||
TRAVEL_ROOT=/data/travel
|
||||
TRAVEL_THUMB_ROOT=/data/thumbs
|
||||
TRAVEL_MEDIA_BASE=/media/travel
|
||||
TRAVEL_CACHE_TTL=300
|
||||
# [SECURITY]
|
||||
WEBHOOK_SECRET=change_this_secret_in_prod
|
||||
|
||||
# CORS (travel-proxy)
|
||||
CORS_ALLOW_ORIGINS=*
|
||||
# [PATHS]
|
||||
# 1. 런타임 데이터 루트 (docker-compose.yml이 실행되는 위치)
|
||||
# NAS: /volume1/docker/webpage
|
||||
# Local: . (현재 프로젝트 루트)
|
||||
RUNTIME_PATH=.
|
||||
|
||||
# 2. Git 저장소 루트
|
||||
# NAS: /volume1/workspace/web-page-backend
|
||||
# Local: .
|
||||
REPO_PATH=.
|
||||
|
||||
# 3. Frontend 정적 파일 경로
|
||||
# NAS: /volume1/docker/webpage/frontend (업로드된 파일)
|
||||
# Local: ./frontend/dist (빌드된 결과물)
|
||||
FRONTEND_PATH=./frontend/dist
|
||||
|
||||
# 4. 여행 사진 원본 경로
|
||||
# NAS: /volume1/web/images/webPage/travel
|
||||
# Local: ./mock_data/photos
|
||||
PHOTO_PATH=./mock_data/photos
|
||||
|
||||
# 5. 주식 데이터 저장 경로
|
||||
# NAS: /volume1/docker/webpage/data/stock
|
||||
# Local: ./data/stock
|
||||
STOCK_DATA_PATH=./data/stock
|
||||
|
||||
# [PERMISSIONS]
|
||||
# NAS: 1026:100
|
||||
# Local: 1000:1000 (Windows Docker Desktop의 경우 크게 중요하지 않음)
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
|
||||
# [STOCK LAB]
|
||||
# NAS는 Windows AI Server로 요청을 중계(Proxy)하는 역할만 수행합니다.
|
||||
# 실제 KIS API 호출 및 AI 분석은 Windows PC에서 수행됩니다.
|
||||
|
||||
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||
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/
|
||||
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건. 진단 커맨드.
|
||||
744
CLAUDE.md
Normal file
744
CLAUDE.md
Normal file
@@ -0,0 +1,744 @@
|
||||
# CLAUDE.md — web-backend 프로젝트 가이드
|
||||
|
||||
> Claude Code가 이 프로젝트를 작업할 때 참조하는 설정 및 구조 문서.
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
- **서비스**: lotto-lab, stock, travel-proxy, music-lab, insta-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
|
||||
---
|
||||
|
||||
## 2. NAS 환경
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|----|
|
||||
| 장비 | Synology NAS |
|
||||
| CPU | Intel Celeron J4025 (2 Core, 2.0 GHz) |
|
||||
| 메모리 | 18 GB |
|
||||
| Docker | Synology Container Manager |
|
||||
| Git 서버 | Gitea (self-hosted, NAS 내부) |
|
||||
| AI 서버 | Windows PC (192.168.45.59:8000) — NVIDIA RTX 5070 Ti (16GB VRAM) + Ollama |
|
||||
|
||||
---
|
||||
|
||||
## 3. NAS 디렉토리 구조
|
||||
|
||||
```
|
||||
/volume1
|
||||
├── docker/webpage/ # 운영 런타임 (Docker Compose 실행 위치)
|
||||
│ ├── lotto/ # lotto 소스 (rsync 동기화)
|
||||
│ ├── stock/ # stock 소스 (rsync 동기화)
|
||||
│ ├── travel-proxy/ # travel-proxy 소스 (rsync 동기화)
|
||||
│ ├── deployer/ # deployer 소스 (rsync 동기화)
|
||||
│ ├── nginx/default.conf # Nginx 설정
|
||||
│ ├── scripts/deploy.sh # Webhook 트리거 배포 스크립트
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── .env # 운영 환경변수
|
||||
│ ├── data/lotto.db # SQLite DB
|
||||
│ └── data/music/ # 생성된 오디오 파일 (music-lab)
|
||||
│
|
||||
├── workspace/web-page-backend/ # Git 레포 클론 위치 (REPO_PATH)
|
||||
│
|
||||
└── web/images/webPage/travel/ # 원본 여행 사진 (RO 마운트)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Docker 서비스 & 포트
|
||||
|
||||
| 컨테이너 | 포트 | 역할 |
|
||||
|---------|------|------|
|
||||
| `lotto` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `stock` | 18500 | 주식 뉴스·AI 분석·KIS 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 + 썸네일 생성 |
|
||||
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
||||
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Nginx 라우팅 규칙
|
||||
|
||||
| 경로 | 프록시 대상 | 비고 |
|
||||
|------|------------|------|
|
||||
| `/api/` | `lotto:8000` | lotto API (기본) |
|
||||
| `/api/travel/` | `travel-proxy:8000` | travel API |
|
||||
| `/api/stock/` | `stock:8000` | stock API |
|
||||
| `/api/trade/` | `stock:8000` | KIS 실계좌 API |
|
||||
| `/api/portfolio` | `stock:8000` | trailing slash 유무 모두 매칭 |
|
||||
| `/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 |
|
||||
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
|
||||
| `/media/videos/` | `/data/videos/` (파일 직접 서빙) | YouTube 영상 MP4 |
|
||||
| `/media/travel/.thumb/` | `/data/thumbs/` (파일 직접 서빙) | 썸네일 캐시 |
|
||||
| `/media/travel/` | `/data/travel/` (파일 직접 서빙) | 원본 사진 |
|
||||
| `/assets/` | 정적 파일 (장기 캐시) | Vite 해시 파일 |
|
||||
| `/` | SPA fallback (`try_files → index.html`) | |
|
||||
|
||||
---
|
||||
|
||||
## 6. 기술 스택
|
||||
|
||||
| 레이어 | 기술 |
|
||||
|--------|------|
|
||||
| Backend 언어 | Python 3.12 |
|
||||
| API 프레임워크 | FastAPI |
|
||||
| DB | SQLite (`/app/data/*.db`) |
|
||||
| 스케줄러 | APScheduler |
|
||||
| 컨테이너 | Docker (`python:3.12-slim` 기반) |
|
||||
| AI 연동 | Ollama (Llama 3.1) — Windows PC (192.168.45.59) |
|
||||
| 주식 API | KIS (한국투자증권) Open API |
|
||||
|
||||
---
|
||||
|
||||
## 7. 자동 배포 흐름
|
||||
|
||||
```
|
||||
개발자 git push → Gitea → Webhook (HMAC SHA256 검증)
|
||||
→ deployer 컨테이너 → /scripts/deploy.sh
|
||||
→ rsync(REPO→RUNTIME) → docker compose up -d --build
|
||||
```
|
||||
|
||||
- **배포 스크립트 위치**: `scripts/deploy-nas.sh` (레포) / `scripts/deploy.sh` (런타임)
|
||||
- **환경변수 파일**: `.env` (RUNTIME_PATH, REPO_PATH, PHOTO_PATH, PUID, PGID 등)
|
||||
- **백업**: `.releases/` 디렉토리에 자동 백업
|
||||
|
||||
---
|
||||
|
||||
## 8. 로컬 개발 환경
|
||||
|
||||
```bash
|
||||
# .env 기본값으로 즉시 실행 가능 (RUNTIME_PATH=., PHOTO_PATH=./mock_data/photos)
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
| 서비스 | 로컬 URL |
|
||||
|--------|----------|
|
||||
| Frontend + API | http://localhost:8080 |
|
||||
| Lotto Backend | http://localhost:18000 |
|
||||
| Travel API | http://localhost:19000 |
|
||||
| Stock Lab | http://localhost:18500 |
|
||||
| Insta Lab | http://localhost:18700 |
|
||||
| Realestate Lab | http://localhost:18800 |
|
||||
| Packs Lab | http://localhost:18950 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 서비스별 핵심 정보
|
||||
|
||||
### lotto-lab (lotto/)
|
||||
- DB: `/app/data/lotto.db`
|
||||
- 데이터 소스: `smok95.github.io/lotto/results/`
|
||||
- 파일 구조: `main.py`, `db.py`, `recommender.py`, `collector.py`, `checker.py`, `generator.py`, `analyzer.py`, `utils.py`, `purchase_manager.py`, `strategy_evolver.py`
|
||||
|
||||
**lotto.db 테이블**
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `draws` | 로또 당첨번호 |
|
||||
| `recommendations` | 추천 이력 (즐겨찾기·태그·채점 포함) |
|
||||
| `simulation_runs` | 시뮬레이션 실행 기록 |
|
||||
| `simulation_candidates` | 시뮬레이션 후보 (점수 5종) |
|
||||
| `best_picks` | 현재 활성 최적 번호 20개 (`is_active` 플래그로 교체) |
|
||||
| `purchase_history` | 구매 이력 (실제/가상, 번호, 전략 출처, 결과) |
|
||||
| `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**
|
||||
- 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개 교체)
|
||||
- 월요일 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 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/lotto/latest` | 최신 당첨번호 |
|
||||
| GET | `/api/lotto/{drw_no}` | 특정 회차 |
|
||||
| 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/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/sync_latest` | 당첨번호 수동 동기화 |
|
||||
| GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) |
|
||||
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
|
||||
| DELETE | `/api/history/{id}` | 삭제 |
|
||||
| GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 피처 |
|
||||
| GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차) |
|
||||
| GET | `/api/lotto/curator/usage` | 큐레이터 토큰·비용 집계 |
|
||||
| POST | `/api/lotto/briefing` | AI 브리핑 저장 |
|
||||
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
|
||||
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
|
||||
| GET | `/api/lotto/briefing` | 브리핑 이력 |
|
||||
| GET | `/api/lotto/evolver/status` | weight_evolver 이번주 trials + current_base + 진행 상황 |
|
||||
| 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 (stock/)
|
||||
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
|
||||
- KIS API 연동으로 실계좌 잔고·거래 조회
|
||||
- 뉴스 스크래핑: 네이버 증권 + 해외 사이트
|
||||
- DB: `/app/data/stock.db` (articles, portfolio, broker_cash, asset_snapshots, sell_history 테이블)
|
||||
- 파일 구조: `main.py`, `db.py`, `scraper.py`, `price_fetcher.py`, `holidays.json`
|
||||
|
||||
**stock API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/stock/news` | 뉴스 조회 (`limit`, `category` 파라미터) |
|
||||
| GET | `/api/stock/indices` | 주요 지표 실시간 조회 |
|
||||
| POST | `/api/stock/scrap` | 수동 뉴스 스크랩 트리거 |
|
||||
| GET | `/api/trade/balance` | 실계좌 잔고 조회 (Windows AI 서버 프록시) |
|
||||
| POST | `/api/trade/order` | 주식 주문 (Windows AI 서버 프록시) |
|
||||
| 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`: 전체, `days=N`: 최근 N건) |
|
||||
| GET | `/api/portfolio/sell-history` | 매도 내역 조회 (`broker`, `days` 필터 선택) |
|
||||
| POST | `/api/portfolio/sell-history` | 매도 기록 저장 (id 포함 레코드 반환) |
|
||||
| PUT | `/api/portfolio/sell-history/{id}` | 매도 기록 수정 (수정된 레코드 반환) |
|
||||
| DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 삭제 |
|
||||
|
||||
**매도 히스토리 (`sell_history`)**
|
||||
- 독립 테이블 — `portfolio` 테이블과 별개로 관리
|
||||
- `sold_at`: UTC ISO8601 형식 (`new Date().toISOString()`)
|
||||
- `realized_profit` / `realized_rate`: 프론트 계산값 저장 (백엔드 재계산 무방)
|
||||
- 응답 정렬: `sold_at DESC` (최신순)
|
||||
|
||||
**총 자산 스냅샷 (`asset_snapshots`)**
|
||||
- 평일 15:40 APScheduler 자동 실행 (`save_daily_snapshot`)
|
||||
- 공휴일 판별: `holidays.json` (매년 수동 갱신, KRX 기준) → `is_market_open()` 함수
|
||||
- 같은 날 중복 저장 시 upsert (date UNIQUE 제약)
|
||||
- 수동 저장: `POST /api/portfolio/snapshot`
|
||||
- 이력 조회: `GET /api/portfolio/snapshot/history?days=30` (ASC 정렬, 차트용)
|
||||
|
||||
**스케줄러 job**
|
||||
- 08:00 매일 — 뉴스 스크랩 (`run_scraping_job`)
|
||||
- 15:40 평일 — 총 자산 스냅샷 저장 (`save_daily_snapshot`)
|
||||
|
||||
### music-lab (music-lab/)
|
||||
- 듀얼 프로바이더 음악 생성 서비스 (Suno API + 로컬 MusicGen) + YouTube 영상 제작 + 시장 조사 트렌드
|
||||
- 생성된 오디오 파일: `/app/data/music/` (Nginx가 `/media/music/`로 직접 서빙)
|
||||
- 생성된 영상 파일: `/app/data/videos/` (Nginx가 `/media/videos/`로 직접 서빙)
|
||||
- DB: `/app/data/music.db` (music_tasks, music_library, video_projects, revenue_records, market_trends, trend_reports 테이블)
|
||||
- 파일 구조: `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 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/music/providers` | 사용 가능한 프로바이더 목록 |
|
||||
| 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` | 라이브러리 전체 조회 |
|
||||
| POST | `/api/music/library` | 트랙 수동 추가 |
|
||||
| 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) |
|
||||
|
||||
**환경변수**
|
||||
- `SUNO_API_KEY`: Suno API 키 (미설정 시 Suno provider 비활성화)
|
||||
- `MUSIC_AI_SERVER_URL`: 로컬 MusicGen 서버 URL (미설정 시 local provider 비활성화)
|
||||
- `MUSIC_MEDIA_BASE`: 오디오 파일 공개 URL prefix (기본 `/media/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`)
|
||||
|
||||
**video_projects 테이블**
|
||||
- format: `visualizer` | `slideshow`
|
||||
- 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/)
|
||||
- 원본 사진: `/data/travel/` (RO)
|
||||
- 썸네일 캐시: `/data/thumbs/` (RW)
|
||||
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
|
||||
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
|
||||
- 지역 오버라이드: `/data/thumbs/region_map_extra.json` (RW, `_regions_meta` 포함)
|
||||
- 파일 구조: `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 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/travel/regions` | 지역 GeoJSON (커스텀 지역 동적 추가 포함) |
|
||||
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
|
||||
| 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/)
|
||||
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
|
||||
- `WEBHOOK_SECRET` 환경변수로 시크릿 관리
|
||||
- Webhook 수신 즉시 `{"ok": True}` 응답 후 BackgroundTask로 배포 실행
|
||||
- 배포 타임아웃: 10분 (`scripts/deploy.sh`)
|
||||
|
||||
---
|
||||
|
||||
## 10. 주의사항
|
||||
|
||||
- **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리
|
||||
- **라우트 순서**: `DELETE /api/todos/done`은 `DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (personal 서비스, FastAPI prefix 매칭 순서)
|
||||
- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입
|
||||
- **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable)
|
||||
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드
|
||||
- **.env 파일**: 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||
- **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 필요 (KRX 기준)
|
||||
- **Windows AI 서버 IP**: `192.168.45.59` — 공유기 DHCP 고정 예약으로 고정. Tailscale은 Synology에서 TCP 불가(userspace 모드)라 로컬 IP 사용
|
||||
- **현재가 조회**: 네이버 모바일 API → HTML 파싱 폴백, 3분 TTL 캐시 (`price_fetcher.py`)
|
||||
- **시뮬레이션 교체 방식**: `best_picks`는 교체형 — 새 시뮬레이션 실행 시 `is_active=0`으로 비활성화 후 신규 입력
|
||||
- **insta-lab Playwright**: NAS에서 chromium 빌드는 가능하지만 +500MB 이미지. 메모리 부족 시 카드 렌더 실패 가능 — 한 번에 1슬레이트만 렌더하도록 직렬화됨
|
||||
393
README.md
393
README.md
@@ -0,0 +1,393 @@
|
||||
# web-backend
|
||||
|
||||
Synology NAS 기반 개인 웹 플랫폼 백엔드 모노레포.
|
||||
로또 분석, 주식 포트폴리오, AI 음악 생성, 인스타 카드 피드, 부동산 청약, AI 에이전트 오피스, 여행 앨범, 개인 서비스(포트폴리오·블로그·투두), NAS 자료 다운로드 자동화를 하나의 Docker Compose 스택으로 운영한다.
|
||||
|
||||
---
|
||||
|
||||
## 서비스 구성
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ frontend (Nginx:8080) │
|
||||
│ ├── 정적 SPA 서빙 (React + Vite) │
|
||||
│ └── API 리버스 프록시 │
|
||||
│ ├── /api/ → lotto:8000 (로또) │
|
||||
│ ├── /api/stock/, /trade/ → stock:8000 │
|
||||
│ ├── /api/portfolio → stock:8000 │
|
||||
│ ├── /api/music/ → music-lab:8000 │
|
||||
│ ├── /api/insta/ → insta-lab:8000 │
|
||||
│ ├── /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` | 18000 | 로또 데이터 수집·분석·추천 API |
|
||||
| `stock` | 18500 | 주식 뉴스·AI 요약·KIS 실계좌·포트폴리오·자산 추적 |
|
||||
| `music-lab` | 18600 | AI 음악 생성 (Suno + 로컬 MusicGen 듀얼 프로바이더) + YouTube 수익화 |
|
||||
| `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 → 자동 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
web-backend/
|
||||
├── lotto/ # 로또 추천·통계·시뮬레이션
|
||||
├── stock/ # 주식·포트폴리오·KIS 연동
|
||||
├── music-lab/ # AI 음악 생성 + YouTube 수익화
|
||||
├── insta-lab/ # 인스타 카드 피드 자동 생성 (Playwright)
|
||||
├── realestate-lab/ # 청약 자동 수집·5티어 매칭
|
||||
├── agent-office/ # AI 에이전트 오피스 (WS + 텔레그램)
|
||||
├── personal/ # 포트폴리오·블로그·투두 통합
|
||||
├── packs-lab/ # NAS 자료 다운로드 자동화 (HMAC + Supabase)
|
||||
├── travel-proxy/ # 여행 사진 + 썸네일
|
||||
├── deployer/ # Gitea Webhook 수신 → 자동 배포
|
||||
├── nginx/default.conf # 리버스 프록시 + SPA + 캐시
|
||||
├── scripts/ # deploy.sh, deploy-nas.sh, healthcheck.sh
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── CLAUDE.md # Claude Code 작업용 상세 컨텍스트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작 (로컬 개발)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
|
||||
curl http://localhost:18000/health
|
||||
curl http://localhost:18500/health
|
||||
```
|
||||
|
||||
| 서비스 | 로컬 URL |
|
||||
|--------|----------|
|
||||
| Frontend + API | http://localhost:8080 |
|
||||
| lotto | http://localhost:18000 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## 서비스별 기능
|
||||
|
||||
### 1. lotto-backend (`/api/`)
|
||||
|
||||
로또 당첨번호 수집·통계 분석·몬테카를로 시뮬레이션 기반 추천 + 투두·블로그 CRUD.
|
||||
|
||||
- **로또**: 당첨번호 조회, 5종 통계 분석, 시뮬레이션 최적 번호(`best_picks` 20쌍), 통계/히트맵/스마트/배치 추천, 전략 가중치(EMA+Softmax), 구매 이력 관리
|
||||
- **추천 이력**: 즐겨찾기·태그·메모 관리
|
||||
- **투두리스트**: UUID PK, 상태(todo/in_progress/done)
|
||||
- **블로그**: 일기형 포스트 (tags JSON 배열, date DESC)
|
||||
|
||||
**스케줄러**
|
||||
- 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`)
|
||||
|
||||
주식 뉴스 스크래핑 + LLM 요약 + KIS 실계좌 연동 + 포트폴리오·자산 스냅샷.
|
||||
|
||||
- **뉴스**: 네이버 증권 + 해외 사이트 크롤링, LLM 기반 한국어 요약
|
||||
- **실계좌**: Windows AI 서버(192.168.45.59:8000) 프록시 → KIS Open API (잔고/주문)
|
||||
- **포트폴리오**: 종목·예수금·매도 히스토리 관리, 현재가 자동 조회
|
||||
- **자산 스냅샷**: 평일 15:40 자동 저장 (KRX 공휴일 판별, `holidays.json` 매년 갱신)
|
||||
|
||||
**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/`)
|
||||
|
||||
듀얼 프로바이더 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/`으로 직접 서빙
|
||||
- **가사 도구**: 저장·편집·타임스탬프 기반 가라오케 동기
|
||||
|
||||
### 4. insta-lab (`/api/insta/`)
|
||||
|
||||
인스타그램 카드 피드 자동 생성 — 뉴스 모니터링 → 키워드 추출 → 10페이지 카드 카피·PNG 렌더 → 텔레그램 푸시 → 사용자 수동 업로드.
|
||||
|
||||
```
|
||||
NAVER 뉴스 + YouTube 인기 (외부 트렌드)
|
||||
→ 카테고리별 빈도 + Claude Haiku 정제 → 트렌딩 키워드
|
||||
→ 사용자가 키워드 선택
|
||||
→ Claude Sonnet으로 10페이지 카피 추론 (커버 1 + 본문 8 + CTA 1)
|
||||
→ Jinja2 + Playwright 1080×1350 PNG 10장 렌더
|
||||
→ 텔레그램 미디어 그룹 + 추천 캡션·해시태그
|
||||
```
|
||||
|
||||
- **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/`)
|
||||
|
||||
공공데이터포털 청약홈 API 연동 + 프로필 기반 자동 매칭.
|
||||
|
||||
- **공고 수집**: 09:00 매일 자동 (`DATA_GO_KR_API_KEY` 필요)
|
||||
- **상태 갱신 + 재매칭**: 00:00 매일 자동
|
||||
- **프로필 매칭**: 지역·주택형·소득·부양가족 등으로 점수화, 신규 매칭 알림
|
||||
- **대시보드**: 진행 중 공고수, 신규 매칭수, 다가오는 일정 요약
|
||||
|
||||
### 6. agent-office (`/api/agent-office/`)
|
||||
|
||||
AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에이전트가 실제 작업을 수행한다.
|
||||
|
||||
- **아키텍처**: stock / music-lab / insta-lab / realestate-lab 기존 API를 서비스 프록시로 호출 (직접 DB 접근 없음)
|
||||
- **FSM 상태**: `idle → working → waiting(승인 대기) → reporting → break`
|
||||
- **실시간 동기화**: WebSocket `/api/agent-office/ws` (init, agent_state, task_complete, command_result)
|
||||
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||
- 봇이 작업 결과를 텔레그램으로 푸시, 명령은 텔레그램에서 바로 에이전트에 전달
|
||||
- Webhook 검증 후 `chat.id` 기준 라우팅
|
||||
|
||||
#### 에이전트 구성
|
||||
|
||||
| 에이전트 | 스케줄 | 승인 | 주요 기능 |
|
||||
|---------|--------|-----|----------|
|
||||
| 📈 **주식 트레이더** (`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분
|
||||
|
||||
---
|
||||
|
||||
## 핵심 로직
|
||||
|
||||
### 몬테카를로 시뮬레이션 (lotto-backend)
|
||||
|
||||
```
|
||||
역대 당첨번호 분석 → 번호별 가중치 산출
|
||||
→ 가중 확률 샘플링으로 후보 20,000개 생성
|
||||
→ 5가지 기법으로 각 조합 점수화
|
||||
→ 상위 100개 DB 저장 → best_picks 20개 교체
|
||||
```
|
||||
|
||||
| 기법 | 가중치 | 내용 |
|
||||
|------|--------|------|
|
||||
| 빈도 Z-score | 25% | 번호 출현 빈도의 표준편차 |
|
||||
| 조합 지문 | 30% | 합계 정규분포 + 홀짝 비율 + 구간분포 |
|
||||
| 갭 분석 | 20% | 마지막 출현 이후 경과 회차 |
|
||||
| 공동 출현 | 15% | 번호 쌍 동시 출현 빈도 |
|
||||
| 다양성 | 10% | 연속번호·범위·구간 커버리지 |
|
||||
|
||||
### LLM 요약 provider 추상화 (stock)
|
||||
|
||||
`ai_summarizer.py`는 provider 분리 구조. `summarize_news(articles)` 시그니처는 provider와 무관하게 고정.
|
||||
|
||||
- `_summarize_with_claude`: Anthropic Messages API 직접 호출 (httpx, SDK 의존성 없음)
|
||||
- `_summarize_with_ollama`: Ollama `/api/generate` (타임아웃 180s, qwen3:14b 첫 로드 대응)
|
||||
- 실패 시 `LLMError` (구 `OllamaError` alias 유지)
|
||||
|
||||
### 총 자산 스냅샷 (stock)
|
||||
|
||||
평일 15:40 자동 실행 → `holidays.json`으로 공휴일 스킵 → 포트폴리오 현재가 조회 + 예수금 합계 → `asset_snapshots` upsert (date UNIQUE).
|
||||
|
||||
### 에이전트 FSM + WS 동기화 (agent-office)
|
||||
|
||||
DB에 저장된 에이전트 상태가 바뀔 때마다 `websocket_manager`가 전체 클라이언트에 브로드캐스트. 텔레그램 봇은 `waiting` 상태 작업에 인라인 키보드를 붙여 승인 요청. 승인/거부 결과가 DB → WS → 프론트로 전파.
|
||||
|
||||
---
|
||||
|
||||
## 자동 배포
|
||||
|
||||
```
|
||||
git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
||||
→ deployer:9000/webhook (서명 검증, compare_digest)
|
||||
→ BackgroundTask: scripts/deploy.sh (10분 타임아웃)
|
||||
1. git pull
|
||||
2. .releases/{timestamp}/ 백업
|
||||
3. rsync (repo → runtime)
|
||||
4. docker compose up -d --build
|
||||
5. chown PUID:PGID
|
||||
```
|
||||
|
||||
> 프론트엔드는 **자동 배포 안 됨** — 로컬 빌드 후 NAS에 수동 업로드 (`scripts/deploy.bat --frontend`)
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
각 서비스는 독립 SQLite DB를 `/app/data/` 볼륨에 저장.
|
||||
|
||||
| DB | 소유 서비스 | 주요 테이블 |
|
||||
|----|------------|-----------|
|
||||
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
|
||||
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
||||
| `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 |
|
||||
| `insta.db` | insta-lab | news_articles, trending_keywords (source 컬럼), card_slates, card_assets, generation_tasks, prompt_templates, account_preferences |
|
||||
| `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log |
|
||||
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages |
|
||||
| `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts |
|
||||
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
||||
| `pack_files` (외부 Supabase) | packs-lab | filename, host_path, mime, byte_size, sha256, deleted_at |
|
||||
|
||||
---
|
||||
|
||||
## 환경변수
|
||||
|
||||
```env
|
||||
# 경로
|
||||
RUNTIME_PATH=.
|
||||
REPO_PATH=.
|
||||
FRONTEND_PATH=./frontend/dist
|
||||
PHOTO_PATH=./mock_data/photos
|
||||
|
||||
# NAS 파일 권한
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
|
||||
# 외부 서비스
|
||||
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
|
||||
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=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 인프라
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|----|
|
||||
| 장비 | Synology NAS (Intel Celeron J4025, 18GB RAM) |
|
||||
| Docker | Synology Container Manager |
|
||||
| Git 서버 | Gitea (NAS 내부 self-hosted, `gahusb.synology.me`) |
|
||||
| AI 서버 | Windows PC (192.168.45.59) — RTX 5070 Ti (16GB VRAM) + Ollama + MusicGen |
|
||||
| Python | 3.12 (`slim` 기반 이미지) |
|
||||
| DB | SQLite (볼륨 마운트로 영속 저장) |
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
- **`.env` 파일** — 절대 커밋 금지. `.env.example`만 레포에 포함
|
||||
- **Nginx trailing slash** — `/api/portfolio`는 두 location 블록으로 처리 (trailing slash 유무 모두 매칭)
|
||||
- **라우트 순서** — `DELETE /api/todos/done`은 `/api/todos/{id}` 보다 먼저 등록 필수 (FastAPI prefix 매칭)
|
||||
- **캐시 전략** — `index.html`: no-store / `assets/`: 1년 immutable
|
||||
- **PUID/PGID** — travel-proxy는 NAS 파일 권한을 위해 환경변수 주입 필수
|
||||
- **공휴일 목록** — `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준)
|
||||
- **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)
|
||||
170
agent-office/app/agents/insta.py
Normal file
170
agent-office/app/agents/insta.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""인스타 카드 에이전트 — 매일 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__)
|
||||
|
||||
|
||||
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:
|
||||
by_cat: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for k in keywords:
|
||||
by_cat.setdefault(k["category"], []).append(k)
|
||||
if not by_cat:
|
||||
await messaging.send_raw("📰 [인스타 큐레이터] 오늘은 추천할 키워드가 없습니다.")
|
||||
return
|
||||
rows: List[List[Dict[str, Any]]] = []
|
||||
text_lines = ["📰 <b>[인스타 큐레이터]</b> 오늘의 키워드 후보"]
|
||||
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
|
||||
40
agent-office/app/config.py
Normal file
40
agent-office/app/config.py
Normal file
@@ -0,0 +1,40 @@
|
||||
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"))
|
||||
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"
|
||||
768
agent-office/app/db.py
Normal file
768
agent-office/app/db.py
Normal file
@@ -0,0 +1,768 @@
|
||||
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'))
|
||||
)
|
||||
""")
|
||||
# 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]
|
||||
264
agent-office/app/main.py
Normal file
264
agent-office/app/main.py
Normal file
@@ -0,0 +1,264 @@
|
||||
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
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(notify_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)
|
||||
35
agent-office/app/models.py
Normal file
35
agent-office/app/models.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
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
|
||||
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}
|
||||
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()
|
||||
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
|
||||
8
agent-office/requirements.txt
Normal file
8
agent-office/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.30.6
|
||||
apscheduler==3.10.4
|
||||
websockets>=12.0
|
||||
httpx>=0.27
|
||||
respx>=0.21
|
||||
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()
|
||||
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
|
||||
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
|
||||
@@ -1,55 +0,0 @@
|
||||
import requests
|
||||
from typing import Dict, Any
|
||||
|
||||
from .db import get_draw, upsert_draw
|
||||
|
||||
def _normalize_item(item: dict) -> dict:
|
||||
# smok95 all.json / latest.json 구조
|
||||
# - draw_no: int
|
||||
# - numbers: [n1..n6]
|
||||
# - bonus_no: int
|
||||
# - date: "YYYY-MM-DD ..."
|
||||
numbers = item["numbers"]
|
||||
return {
|
||||
"drw_no": int(item["draw_no"]),
|
||||
"drw_date": (item.get("date") or "")[:10],
|
||||
"n1": int(numbers[0]),
|
||||
"n2": int(numbers[1]),
|
||||
"n3": int(numbers[2]),
|
||||
"n4": int(numbers[3]),
|
||||
"n5": int(numbers[4]),
|
||||
"n6": int(numbers[5]),
|
||||
"bonus": int(item["bonus_no"]),
|
||||
}
|
||||
|
||||
def sync_all_from_json(all_url: str) -> Dict[str, Any]:
|
||||
r = requests.get(all_url, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json() # list[dict]
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
|
||||
for item in data:
|
||||
row = _normalize_item(item)
|
||||
|
||||
if get_draw(row["drw_no"]):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
upsert_draw(row)
|
||||
inserted += 1
|
||||
|
||||
return {"mode": "all_json", "url": all_url, "inserted": inserted, "skipped": skipped, "total": len(data)}
|
||||
|
||||
def sync_latest(latest_url: str) -> Dict[str, Any]:
|
||||
r = requests.get(latest_url, timeout=30)
|
||||
r.raise_for_status()
|
||||
item = r.json()
|
||||
|
||||
row = _normalize_item(item)
|
||||
before = get_draw(row["drw_no"])
|
||||
upsert_draw(row)
|
||||
|
||||
return {"mode": "latest_json", "url": latest_url, "was_new": (before is None), "drawNo": row["drw_no"]}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
# backend/app/db.py
|
||||
import os
|
||||
import sqlite3
|
||||
import json
|
||||
import hashlib
|
||||
from typing import Any, Dict, Optional, List
|
||||
|
||||
DB_PATH = "/app/data/lotto.db"
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, ddl: str) -> None:
|
||||
cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if col not in cols:
|
||||
conn.execute(ddl)
|
||||
|
||||
def init_db() -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS draws (
|
||||
drw_no INTEGER PRIMARY KEY,
|
||||
drw_date TEXT NOT NULL,
|
||||
n1 INTEGER NOT NULL,
|
||||
n2 INTEGER NOT NULL,
|
||||
n3 INTEGER NOT NULL,
|
||||
n4 INTEGER NOT NULL,
|
||||
n5 INTEGER NOT NULL,
|
||||
n6 INTEGER NOT NULL,
|
||||
bonus INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_draws_date ON draws(drw_date);")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS recommendations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
based_on_draw INTEGER,
|
||||
numbers TEXT NOT NULL,
|
||||
params TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_reco_created ON recommendations(created_at DESC);")
|
||||
|
||||
# ✅ 확장 컬럼들(기존 DB에도 자동 추가)
|
||||
_ensure_column(conn, "recommendations", "numbers_sorted",
|
||||
"ALTER TABLE recommendations ADD COLUMN numbers_sorted TEXT;")
|
||||
_ensure_column(conn, "recommendations", "dedup_hash",
|
||||
"ALTER TABLE recommendations ADD COLUMN dedup_hash TEXT;")
|
||||
_ensure_column(conn, "recommendations", "favorite",
|
||||
"ALTER TABLE recommendations ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;")
|
||||
_ensure_column(conn, "recommendations", "note",
|
||||
"ALTER TABLE recommendations ADD COLUMN note TEXT NOT NULL DEFAULT '';")
|
||||
_ensure_column(conn, "recommendations", "tags",
|
||||
"ALTER TABLE recommendations ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';")
|
||||
|
||||
# ✅ UNIQUE 인덱스(중복 저장 방지)
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_reco_dedup ON recommendations(dedup_hash);")
|
||||
|
||||
def upsert_draw(row: Dict[str, Any]) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(drw_no) DO UPDATE SET
|
||||
drw_date=excluded.drw_date,
|
||||
n1=excluded.n1, n2=excluded.n2, n3=excluded.n3,
|
||||
n4=excluded.n4, n5=excluded.n5, n6=excluded.n6,
|
||||
bonus=excluded.bonus,
|
||||
updated_at=datetime('now')
|
||||
""",
|
||||
(
|
||||
int(row["drw_no"]),
|
||||
str(row["drw_date"]),
|
||||
int(row["n1"]), int(row["n2"]), int(row["n3"]),
|
||||
int(row["n4"]), int(row["n5"]), int(row["n6"]),
|
||||
int(row["bonus"]),
|
||||
),
|
||||
)
|
||||
|
||||
def get_latest_draw() -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM draws ORDER BY drw_no DESC LIMIT 1").fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
def get_draw(drw_no: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT * FROM draws WHERE drw_no = ?", (drw_no,)).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
def count_draws() -> int:
|
||||
with _conn() as conn:
|
||||
r = conn.execute("SELECT COUNT(*) AS c FROM draws").fetchone()
|
||||
return int(r["c"])
|
||||
|
||||
def get_all_draw_numbers():
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT drw_no, n1, n2, n3, n4, n5, n6 FROM draws ORDER BY drw_no ASC"
|
||||
).fetchall()
|
||||
return [(int(r["drw_no"]), [int(r["n1"]), int(r["n2"]), int(r["n3"]), int(r["n4"]), int(r["n5"]), int(r["n6"])]) for r in rows]
|
||||
|
||||
# ---------- ✅ recommendation helpers ----------
|
||||
|
||||
def _canonical_params(params: dict) -> str:
|
||||
return json.dumps(params, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
def _numbers_sorted_str(numbers: List[int]) -> str:
|
||||
return ",".join(str(x) for x in sorted(numbers))
|
||||
|
||||
def _dedup_hash(based_on_draw: Optional[int], numbers: List[int], params: dict) -> str:
|
||||
s = f"{based_on_draw or ''}|{_numbers_sorted_str(numbers)}|{_canonical_params(params)}"
|
||||
return hashlib.sha1(s.encode("utf-8")).hexdigest()
|
||||
|
||||
def save_recommendation_dedup(based_on_draw: Optional[int], numbers: List[int], params: dict) -> Dict[str, Any]:
|
||||
"""
|
||||
✅ 동일 추천(번호+params+based_on_draw)이면 중복 저장 없이 기존 id 반환
|
||||
"""
|
||||
ns = _numbers_sorted_str(numbers)
|
||||
h = _dedup_hash(based_on_draw, numbers, params)
|
||||
|
||||
with _conn() as conn:
|
||||
# 이미 있으면 반환
|
||||
r = conn.execute("SELECT id FROM recommendations WHERE dedup_hash = ?", (h,)).fetchone()
|
||||
if r:
|
||||
return {"id": int(r["id"]), "saved": False, "deduped": True}
|
||||
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO recommendations (based_on_draw, numbers, params, numbers_sorted, dedup_hash)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(based_on_draw, json.dumps(numbers), json.dumps(params), ns, h),
|
||||
)
|
||||
return {"id": int(cur.lastrowid), "saved": True, "deduped": False}
|
||||
|
||||
def list_recommendations_ex(
|
||||
limit: int = 30,
|
||||
offset: int = 0,
|
||||
favorite: Optional[bool] = None,
|
||||
tag: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
sort: str = "id_desc", # id_desc|created_desc|favorite_desc
|
||||
) -> List[Dict[str, Any]]:
|
||||
import json
|
||||
|
||||
where = []
|
||||
args: list[Any] = []
|
||||
|
||||
if favorite is not None:
|
||||
where.append("favorite = ?")
|
||||
args.append(1 if favorite else 0)
|
||||
|
||||
if q:
|
||||
where.append("note LIKE ?")
|
||||
args.append(f"%{q}%")
|
||||
|
||||
# tags는 JSON 문자열이므로 단순 LIKE로 처리(가볍게 시작)
|
||||
if tag:
|
||||
where.append("tags LIKE ?")
|
||||
args.append(f"%{tag}%")
|
||||
|
||||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
|
||||
if sort == "created_desc":
|
||||
order = "created_at DESC"
|
||||
elif sort == "favorite_desc":
|
||||
# favorite(1)이 먼저, 그 다음 최신
|
||||
order = "favorite DESC, id DESC"
|
||||
else:
|
||||
order = "id DESC"
|
||||
|
||||
sql = f"""
|
||||
SELECT id, created_at, based_on_draw, numbers, params, favorite, note, tags
|
||||
FROM recommendations
|
||||
{where_sql}
|
||||
ORDER BY {order}
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
args.extend([int(limit), int(offset)])
|
||||
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(sql, args).fetchall()
|
||||
|
||||
out = []
|
||||
for r in rows:
|
||||
out.append({
|
||||
"id": int(r["id"]),
|
||||
"created_at": r["created_at"],
|
||||
"based_on_draw": r["based_on_draw"],
|
||||
"numbers": json.loads(r["numbers"]),
|
||||
"params": json.loads(r["params"]),
|
||||
"favorite": bool(r["favorite"]) if r["favorite"] is not None else False,
|
||||
"note": r["note"],
|
||||
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
||||
})
|
||||
return out
|
||||
|
||||
def update_recommendation(rec_id: int, favorite: Optional[bool] = None, note: Optional[str] = None, tags: Optional[List[str]] = None) -> bool:
|
||||
fields = []
|
||||
args: list[Any] = []
|
||||
|
||||
if favorite is not None:
|
||||
fields.append("favorite = ?")
|
||||
args.append(1 if favorite else 0)
|
||||
if note is not None:
|
||||
fields.append("note = ?")
|
||||
args.append(note)
|
||||
if tags is not None:
|
||||
fields.append("tags = ?")
|
||||
args.append(json.dumps(tags))
|
||||
|
||||
if not fields:
|
||||
return False
|
||||
|
||||
args.append(rec_id)
|
||||
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(
|
||||
f"UPDATE recommendations SET {', '.join(fields)} WHERE id = ?",
|
||||
args,
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
def delete_recommendation(rec_id: int) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
import os
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from .db import (
|
||||
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
|
||||
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
||||
update_recommendation,
|
||||
)
|
||||
from .recommender import recommend_numbers
|
||||
from .collector import sync_latest
|
||||
|
||||
app = FastAPI()
|
||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||
|
||||
ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json")
|
||||
LATEST_URL = os.getenv("LOTTO_LATEST_URL", "https://smok95.github.io/lotto/results/latest.json")
|
||||
|
||||
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
|
||||
nums = sorted(numbers)
|
||||
s = sum(nums)
|
||||
odd = sum(1 for x in nums if x % 2 == 1)
|
||||
even = len(nums) - odd
|
||||
mn, mx = nums[0], nums[-1]
|
||||
rng = mx - mn
|
||||
|
||||
# 1-10, 11-20, 21-30, 31-40, 41-45
|
||||
buckets = {
|
||||
"1-10": 0,
|
||||
"11-20": 0,
|
||||
"21-30": 0,
|
||||
"31-40": 0,
|
||||
"41-45": 0,
|
||||
}
|
||||
for x in nums:
|
||||
if 1 <= x <= 10:
|
||||
buckets["1-10"] += 1
|
||||
elif 11 <= x <= 20:
|
||||
buckets["11-20"] += 1
|
||||
elif 21 <= x <= 30:
|
||||
buckets["21-30"] += 1
|
||||
elif 31 <= x <= 40:
|
||||
buckets["31-40"] += 1
|
||||
else:
|
||||
buckets["41-45"] += 1
|
||||
|
||||
return {
|
||||
"sum": s,
|
||||
"odd": odd,
|
||||
"even": even,
|
||||
"min": mn,
|
||||
"max": mx,
|
||||
"range": rng,
|
||||
"buckets": buckets,
|
||||
}
|
||||
|
||||
def calc_recent_overlap(numbers: List[int], draws: List[Tuple[int, List[int]]], last_k: int) -> Dict[str, Any]:
|
||||
"""
|
||||
draws: [(drw_no, [n1..n6]), ...] 오름차순
|
||||
last_k: 최근 k회 기준 중복
|
||||
"""
|
||||
if last_k <= 0:
|
||||
return {"last_k": 0, "repeats": 0, "repeated_numbers": []}
|
||||
|
||||
recent = draws[-last_k:] if len(draws) >= last_k else draws
|
||||
recent_set = set()
|
||||
for _, nums in recent:
|
||||
recent_set.update(nums)
|
||||
|
||||
repeated = sorted(set(numbers) & recent_set)
|
||||
return {
|
||||
"last_k": len(recent),
|
||||
"repeats": len(repeated),
|
||||
"repeated_numbers": repeated,
|
||||
}
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
scheduler.add_job(lambda: sync_latest(LATEST_URL), "cron", hour="9,21", minute=10)
|
||||
scheduler.start()
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/lotto/latest")
|
||||
def api_latest():
|
||||
row = get_latest_draw()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
return {
|
||||
"drawNo": row["drw_no"],
|
||||
"date": row["drw_date"],
|
||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||
"bonus": row["bonus"],
|
||||
}
|
||||
|
||||
@app.get("/api/lotto/{drw_no:int}")
|
||||
def api_draw(drw_no: int):
|
||||
row = get_draw(drw_no)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return {
|
||||
"drwNo": row["drw_no"],
|
||||
"date": row["drw_date"],
|
||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||
"bonus": row["bonus"],
|
||||
}
|
||||
|
||||
@app.post("/api/admin/sync_latest")
|
||||
def admin_sync_latest():
|
||||
return sync_latest(LATEST_URL)
|
||||
|
||||
# ---------- ✅ recommend (dedup save) ----------
|
||||
@app.get("/api/lotto/recommend")
|
||||
def api_recommend(
|
||||
recent_window: int = 200,
|
||||
recent_weight: float = 2.0,
|
||||
avoid_recent_k: int = 5,
|
||||
|
||||
# ---- optional constraints (Lotto Lab) ----
|
||||
sum_min: Optional[int] = None,
|
||||
sum_max: Optional[int] = None,
|
||||
odd_min: Optional[int] = None,
|
||||
odd_max: Optional[int] = None,
|
||||
range_min: Optional[int] = None,
|
||||
range_max: Optional[int] = None,
|
||||
max_overlap_latest: Optional[int] = None, # 최근 avoid_recent_k 회차와 중복 허용 개수
|
||||
max_try: int = 200, # 조건 맞는 조합 찾기 재시도
|
||||
):
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
|
||||
latest = get_latest_draw()
|
||||
|
||||
params = {
|
||||
"recent_window": recent_window,
|
||||
"recent_weight": float(recent_weight),
|
||||
"avoid_recent_k": avoid_recent_k,
|
||||
|
||||
"sum_min": sum_min,
|
||||
"sum_max": sum_max,
|
||||
"odd_min": odd_min,
|
||||
"odd_max": odd_max,
|
||||
"range_min": range_min,
|
||||
"range_max": range_max,
|
||||
"max_overlap_latest": max_overlap_latest,
|
||||
"max_try": int(max_try),
|
||||
}
|
||||
|
||||
def _accept(nums: List[int]) -> bool:
|
||||
m = calc_metrics(nums)
|
||||
if sum_min is not None and m["sum"] < sum_min:
|
||||
return False
|
||||
if sum_max is not None and m["sum"] > sum_max:
|
||||
return False
|
||||
if odd_min is not None and m["odd"] < odd_min:
|
||||
return False
|
||||
if odd_max is not None and m["odd"] > odd_max:
|
||||
return False
|
||||
if range_min is not None and m["range"] < range_min:
|
||||
return False
|
||||
if range_max is not None and m["range"] > range_max:
|
||||
return False
|
||||
|
||||
if max_overlap_latest is not None:
|
||||
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
||||
if ov["repeats"] > max_overlap_latest:
|
||||
return False
|
||||
return True
|
||||
|
||||
chosen = None
|
||||
explain = None
|
||||
|
||||
tries = 0
|
||||
while tries < max_try:
|
||||
tries += 1
|
||||
result = recommend_numbers(
|
||||
draws,
|
||||
recent_window=recent_window,
|
||||
recent_weight=recent_weight,
|
||||
avoid_recent_k=avoid_recent_k,
|
||||
)
|
||||
nums = result["numbers"]
|
||||
if _accept(nums):
|
||||
chosen = nums
|
||||
explain = result["explain"]
|
||||
break
|
||||
|
||||
if chosen is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Constraints too strict. No valid set found in max_try={max_try}. "
|
||||
f"Try relaxing sum/odd/range/overlap constraints.",
|
||||
)
|
||||
|
||||
# ✅ dedup save
|
||||
saved = save_recommendation_dedup(
|
||||
latest["drw_no"] if latest else None,
|
||||
chosen,
|
||||
params,
|
||||
)
|
||||
|
||||
metrics = calc_metrics(chosen)
|
||||
overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k)
|
||||
|
||||
return {
|
||||
"id": saved["id"],
|
||||
"saved": saved["saved"],
|
||||
"deduped": saved["deduped"],
|
||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||
"numbers": chosen,
|
||||
"explain": explain,
|
||||
"params": params,
|
||||
"metrics": metrics,
|
||||
"recent_overlap": overlap,
|
||||
"tries": tries,
|
||||
}
|
||||
|
||||
# ---------- ✅ history list (filter/paging) ----------
|
||||
@app.get("/api/history")
|
||||
def api_history(
|
||||
limit: int = 30,
|
||||
offset: int = 0,
|
||||
favorite: Optional[bool] = None,
|
||||
tag: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
sort: str = "id_desc",
|
||||
):
|
||||
items = list_recommendations_ex(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
favorite=favorite,
|
||||
tag=tag,
|
||||
q=q,
|
||||
sort=sort,
|
||||
)
|
||||
|
||||
draws = get_all_draw_numbers()
|
||||
|
||||
out = []
|
||||
for it in items:
|
||||
nums = it["numbers"]
|
||||
out.append({
|
||||
**it,
|
||||
"metrics": calc_metrics(nums),
|
||||
"recent_overlap": calc_recent_overlap(
|
||||
nums, draws, last_k=int(it["params"].get("avoid_recent_k", 0) or 0)
|
||||
),
|
||||
})
|
||||
|
||||
return {
|
||||
"items": out,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"filters": {"favorite": favorite, "tag": tag, "q": q, "sort": sort},
|
||||
}
|
||||
|
||||
@app.delete("/api/history/{rec_id:int}")
|
||||
def api_history_delete(rec_id: int):
|
||||
ok = delete_recommendation(rec_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return {"deleted": True, "id": rec_id}
|
||||
|
||||
# ---------- ✅ history update (favorite/note/tags) ----------
|
||||
class HistoryUpdate(BaseModel):
|
||||
favorite: Optional[bool] = None
|
||||
note: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
@app.patch("/api/history/{rec_id:int}")
|
||||
def api_history_patch(rec_id: int, body: HistoryUpdate):
|
||||
ok = update_recommendation(rec_id, favorite=body.favorite, note=body.note, tags=body.tags)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Not found or no changes")
|
||||
return {"updated": True, "id": rec_id}
|
||||
|
||||
# ---------- ✅ batch recommend ----------
|
||||
def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, avoid_recent_k: int, max_try: int = 200):
|
||||
items = []
|
||||
seen = set()
|
||||
|
||||
tries = 0
|
||||
while len(items) < count and tries < max_try:
|
||||
tries += 1
|
||||
r = recommend_numbers(draws, recent_window=recent_window, recent_weight=recent_weight, avoid_recent_k=avoid_recent_k)
|
||||
key = tuple(sorted(r["numbers"]))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
items.append(r)
|
||||
|
||||
return items
|
||||
|
||||
@app.get("/api/lotto/recommend/batch")
|
||||
def api_recommend_batch(
|
||||
count: int = 5,
|
||||
recent_window: int = 200,
|
||||
recent_weight: float = 2.0,
|
||||
avoid_recent_k: int = 5,
|
||||
):
|
||||
count = max(1, min(count, 20))
|
||||
draws = get_all_draw_numbers()
|
||||
if not draws:
|
||||
raise HTTPException(status_code=404, detail="No data yet")
|
||||
|
||||
latest = get_latest_draw()
|
||||
params = {
|
||||
"recent_window": recent_window,
|
||||
"recent_weight": float(recent_weight),
|
||||
"avoid_recent_k": avoid_recent_k,
|
||||
"count": count,
|
||||
}
|
||||
|
||||
items = _batch_unique(draws, count, recent_window, float(recent_weight), avoid_recent_k)
|
||||
|
||||
return {
|
||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||
"count": count,
|
||||
"items": [{"numbers": it["numbers"], "explain": it["explain"]} for it in items],
|
||||
"params": params,
|
||||
}
|
||||
|
||||
class BatchSave(BaseModel):
|
||||
items: List[List[int]]
|
||||
params: dict
|
||||
|
||||
@app.post("/api/lotto/recommend/batch")
|
||||
def api_recommend_batch_save(body: BatchSave):
|
||||
latest = get_latest_draw()
|
||||
based = latest["drw_no"] if latest else None
|
||||
|
||||
created, deduped = [], []
|
||||
for nums in body.items:
|
||||
saved = save_recommendation_dedup(based, nums, body.params)
|
||||
(created if saved["saved"] else deduped).append(saved["id"])
|
||||
|
||||
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import random
|
||||
from collections import Counter
|
||||
from typing import Dict, Any, List, Tuple
|
||||
|
||||
def recommend_numbers(
|
||||
draws: List[Tuple[int, List[int]]],
|
||||
*,
|
||||
recent_window: int = 200,
|
||||
recent_weight: float = 2.0,
|
||||
avoid_recent_k: int = 5,
|
||||
seed: int | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
가벼운 통계 기반 추천:
|
||||
- 전체 빈도 + 최근(recent_window) 빈도에 가중치를 더한 가중 샘플링
|
||||
- 최근 avoid_recent_k 회차에 나온 번호는 확률을 낮춤(완전 제외는 아님)
|
||||
"""
|
||||
if seed is not None:
|
||||
random.seed(seed)
|
||||
|
||||
# 전체 빈도
|
||||
all_nums = [n for _, nums in draws for n in nums]
|
||||
freq_all = Counter(all_nums)
|
||||
|
||||
# 최근 빈도
|
||||
recent = draws[-recent_window:] if len(draws) >= recent_window else draws
|
||||
recent_nums = [n for _, nums in recent for n in nums]
|
||||
freq_recent = Counter(recent_nums)
|
||||
|
||||
# 최근 k회차 번호(패널티)
|
||||
last_k = draws[-avoid_recent_k:] if len(draws) >= avoid_recent_k else draws
|
||||
last_k_nums = set(n for _, nums in last_k for n in nums)
|
||||
|
||||
# 가중치 구성
|
||||
weights = {}
|
||||
for n in range(1, 46):
|
||||
w = freq_all[n] + recent_weight * freq_recent[n]
|
||||
if n in last_k_nums:
|
||||
w *= 0.6 # 최근에 너무 방금 나온 건 살짝 덜 뽑히게
|
||||
weights[n] = max(w, 0.1)
|
||||
|
||||
# 중복 없이 6개 뽑기(가중 샘플링)
|
||||
chosen = []
|
||||
pool = list(range(1, 46))
|
||||
for _ in range(6):
|
||||
total = sum(weights[n] for n in pool)
|
||||
r = random.random() * total
|
||||
acc = 0.0
|
||||
for n in pool:
|
||||
acc += weights[n]
|
||||
if acc >= r:
|
||||
chosen.append(n)
|
||||
pool.remove(n)
|
||||
break
|
||||
|
||||
chosen_sorted = sorted(chosen)
|
||||
|
||||
explain = {
|
||||
"recent_window": recent_window,
|
||||
"recent_weight": recent_weight,
|
||||
"avoid_recent_k": avoid_recent_k,
|
||||
"top_all": [n for n, _ in freq_all.most_common(10)],
|
||||
"top_recent": [n for n, _ in freq_recent.most_common(10)],
|
||||
"last_k_draws": [d for d, _ in last_k],
|
||||
}
|
||||
|
||||
return {"numbers": chosen_sorted, "explain": explain}
|
||||
|
||||
6
deployer/.dockerignore
Normal file
6
deployer/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
24
deployer/Dockerfile
Normal file
24
deployer/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Docker CE CLI + Compose Plugin (공식 저장소에서 설치)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git rsync ca-certificates curl util-linux gnupg \
|
||||
&& 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/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app.py /app/app.py
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
EXPOSE 9000
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "9000"]
|
||||
79
deployer/app.py
Normal file
79
deployer/app.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import os, hmac, hashlib, subprocess, threading
|
||||
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import JSONResponse
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S %Z",
|
||||
)
|
||||
logger = logging.getLogger("deployer")
|
||||
|
||||
app = FastAPI()
|
||||
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:
|
||||
if not SECRET or not sig:
|
||||
return False
|
||||
|
||||
mac = hmac.new(SECRET.encode(), msg=body, digestmod=hashlib.sha256).hexdigest()
|
||||
candidates = {mac, f"sha256={mac}"}
|
||||
return any(hmac.compare_digest(sig, c) for c in candidates)
|
||||
|
||||
def run_deploy_script():
|
||||
"""배포 스크립트를 백그라운드에서 실행 (동시 실행 방지)"""
|
||||
if not _deploy_lock.acquire(blocking=False):
|
||||
logger.info("Deploy already in progress, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info("Starting deployment script...")
|
||||
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
|
||||
|
||||
if p.returncode == 0:
|
||||
logger.info(f"Deployment SUCCESS:\n{p.stdout}")
|
||||
else:
|
||||
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:
|
||||
logger.exception(f"Exception during deployment: {e}")
|
||||
finally:
|
||||
_deploy_lock.release()
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "healthy", "service": "deployer"}
|
||||
|
||||
@app.post("/webhook")
|
||||
async def webhook(req: Request, background_tasks: BackgroundTasks):
|
||||
body = await req.body()
|
||||
|
||||
sig = (
|
||||
req.headers.get("X-Gitea-Signature")
|
||||
or req.headers.get("X-Hub-Signature-256")
|
||||
or ""
|
||||
)
|
||||
|
||||
if not verify(sig, body):
|
||||
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 응답을 주고, 배포는 뒤에서 실행
|
||||
background_tasks.add_task(run_deploy_script)
|
||||
|
||||
return {"ok": True, "message": "Deployment started in background"}
|
||||
|
||||
2
deployer/requirements.txt
Normal file
2
deployer/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
@@ -1,9 +1,12 @@
|
||||
version: "3.8"
|
||||
name: webpage
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: lotto-backend
|
||||
lotto:
|
||||
build:
|
||||
context: ./lotto
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
container_name: lotto
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18000:8000"
|
||||
@@ -12,36 +15,327 @@ services:
|
||||
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
|
||||
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
||||
volumes:
|
||||
- /volume1/docker/webpage/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:
|
||||
build:
|
||||
context: ./stock
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
container_name: stock
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18500:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- 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:
|
||||
- ${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:
|
||||
build:
|
||||
context: ./music-lab
|
||||
container_name: music-lab
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18600:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-}
|
||||
- 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:
|
||||
- ${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
|
||||
|
||||
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:
|
||||
build: ./travel-proxy
|
||||
container_name: travel-proxy
|
||||
restart: unless-stopped
|
||||
user: "1026:100"
|
||||
user: "${PUID}:${PGID}"
|
||||
ports:
|
||||
- "19000:8000" # 내부 확인용
|
||||
- "19000:8000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel}
|
||||
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
|
||||
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
|
||||
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
|
||||
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
|
||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||
volumes:
|
||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:rw
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
- ${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:
|
||||
image: nginx:alpine
|
||||
container_name: lotto-frontend
|
||||
container_name: frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- lotto
|
||||
- stock
|
||||
- music-lab
|
||||
- insta-lab
|
||||
- realestate-lab
|
||||
- agent-office
|
||||
- personal
|
||||
- packs-lab
|
||||
- travel-proxy
|
||||
- video-lab
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- /volume1/docker/webpage/frontend:/usr/share/nginx/html:ro
|
||||
- /volume1/docker/webpage/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:ro
|
||||
- ${FRONTEND_PATH}:/usr/share/nginx/html:ro
|
||||
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ${PHOTO_PATH}:/data/travel:ro
|
||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs: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:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
deployer:
|
||||
build: ./deployer
|
||||
container_name: webpage-deployer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:19010:9000"
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Seoul}
|
||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||
- PUID=${PUID:-1026}
|
||||
- PGID=${PGID:-100}
|
||||
volumes:
|
||||
- ${REPO_PATH}:/repo:rw
|
||||
- ${RUNTIME_PATH}:/runtime:rw
|
||||
- ${RUNTIME_PATH}/scripts:/scripts:ro
|
||||
- /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 대비)
|
||||
1618
docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md
Normal file
1618
docs/superpowers/plans/2026-05-23-lotto-evolver-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
163
docs/superpowers/specs/2026-04-07-pet-lab-design.md
Normal file
163
docs/superpowers/specs/2026-04-07-pet-lab-design.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Pet Lab - Desktop Pet Application Design
|
||||
|
||||
## Overview
|
||||
|
||||
Windows PC 바탕화면에 항상 떠있는 데스크톱 펫 애플리케이션. 캐릭터(박뚱냥)가 화면 하단에 고정되어 마우스 방향으로 시선을 추적하고, 클릭/우클릭으로 상호작용할 수 있다.
|
||||
|
||||
**프로젝트 위치**: `C:\Users\jaeoh\Desktop\workspace\pet-lab` (독립 프로젝트, web-backend 모노레포 외부)
|
||||
|
||||
**기술 스택**: Python 3.12 + PyQt5
|
||||
|
||||
**배포**: 로컬 Windows PC 실행 전용 (NAS 배포 불필요). 추후 PyInstaller로 .exe 패킹.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
pet-lab/
|
||||
├── app/
|
||||
│ ├── main.py # 엔트리포인트 (QApplication 초기화, 시스템 트레이)
|
||||
│ ├── pet_widget.py # 메인 위젯 (투명 윈도우 + 캐릭터 렌더링)
|
||||
│ ├── eye_tracker.py # 마우스 위치 기반 시선/기울기 계산
|
||||
│ ├── interaction.py # 클릭 반응 애니메이션 + 우클릭 컨텍스트 메뉴
|
||||
│ └── config.py # 설정값 (크기, 위치, 속도 상수)
|
||||
├── assets/
|
||||
│ └── characters/
|
||||
│ └── 박뚱냥.png # 캐릭터 이미지 (투명 배경 PNG)
|
||||
├── requirements.txt # PyQt5
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `main.py` | QApplication 생성, PetWidget 인스턴스화, 이벤트 루프 시작 |
|
||||
| `pet_widget.py` | 투명 프레임리스 윈도우, 캐릭터 이미지 표시, QTimer 루프로 시선 업데이트 |
|
||||
| `eye_tracker.py` | 마우스 좌표 → 기울기 각도/좌우 반전 여부 계산 (순수 계산 모듈) |
|
||||
| `interaction.py` | 좌클릭(점프), 더블클릭(흔들기) 애니메이션, 우클릭 메뉴 생성/처리 |
|
||||
| `config.py` | 상수 정의: 캐릭터 크기(소/중/대), 틸트 범위, 타이머 간격 등 |
|
||||
|
||||
---
|
||||
|
||||
## Core Behavior
|
||||
|
||||
### 투명 윈도우
|
||||
|
||||
PyQt5 윈도우 플래그 조합:
|
||||
- `Qt.FramelessWindowHint`: 타이틀바 제거
|
||||
- `Qt.WindowStaysOnTopHint`: 항상 위 (토글 가능)
|
||||
- `Qt.Tool`: 태스크바에 표시 안 함
|
||||
- `WA_TranslucentBackground`: 배경 투명
|
||||
|
||||
캐릭터 이미지 영역만 클릭 이벤트 수신. 투명 영역은 `WA_TransparentForMouseEvents`가 아닌, 위젯 크기를 캐릭터 이미지 크기에 맞춰서 처리.
|
||||
|
||||
### 바닥 고정 위치
|
||||
|
||||
- Y = 화면 높이 - 태스크바 높이(기본 48px) - 캐릭터 높이
|
||||
- X = 수평 위치 프리셋: 좌(화면 10%), 중앙(50%), 우(90%)
|
||||
- 기본 위치: 화면 우측(90%)
|
||||
- 태스크바 높이는 Windows API 없이 기본값 48px 사용 (충분히 실용적)
|
||||
|
||||
### 시선 추적
|
||||
|
||||
QTimer(30ms 간격, 약 33fps)로 글로벌 마우스 좌표 폴링:
|
||||
|
||||
1. `QCursor.pos()`로 마우스 절대 좌표 획득
|
||||
2. 캐릭터 중심점과 마우스 사이의 각도 계산 (`math.atan2`)
|
||||
3. 각도를 기울기로 변환:
|
||||
- 마우스가 캐릭터 왼쪽 → 이미지 좌측 기울기 (음수 각도)
|
||||
- 마우스가 캐릭터 오른쪽 → 이미지 우측 기울기 (양수 각도)
|
||||
- 기울기 범위: -15도 ~ +15도
|
||||
4. 마우스가 캐릭터 왼쪽이면 이미지 좌우 반전 (`QTransform.scale(-1, 1)`)
|
||||
5. `QTransform.rotate(angle)`로 기울기 적용
|
||||
6. 마우스 좌표 변화 없으면 렌더링 스킵 (성능 최적화)
|
||||
|
||||
### 클릭 반응
|
||||
|
||||
**좌클릭 — 점프**:
|
||||
- `QPropertyAnimation`으로 위젯 Y좌표를 위로 30px 이동 후 복귀
|
||||
- duration: 300ms, easing: `QEasingCurve.OutBounce`
|
||||
|
||||
**더블클릭 — 흔들기**:
|
||||
- `QPropertyAnimation`으로 X좌표를 좌우 진동
|
||||
- duration: 400ms, 좌(-10) → 우(+10) → 원위치
|
||||
|
||||
### 우클릭 컨텍스트 메뉴
|
||||
|
||||
| 메뉴 항목 | 동작 |
|
||||
|-----------|------|
|
||||
| 위치: 좌/중앙/우 | 캐릭터 수평 위치 변경 |
|
||||
| 크기: 소/중/대 | 캐릭터 크기 변경 (100/150/200px) |
|
||||
| 항상 위 | `WindowStaysOnTopHint` 토글 |
|
||||
| 종료 | 애플리케이션 종료 |
|
||||
|
||||
`QMenu`로 구현. 서브메뉴 사용하여 위치/크기를 그룹화.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Constants (`config.py`)
|
||||
|
||||
```python
|
||||
# 캐릭터 크기 (높이 기준, 너비는 비율 유지)
|
||||
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 # 약 33fps
|
||||
MAX_TILT_ANGLE = 15.0 # 최대 기울기 (도)
|
||||
|
||||
# 태스크바
|
||||
TASKBAR_HEIGHT = 48 # Windows 기본 태스크바 높이
|
||||
|
||||
# 애니메이션
|
||||
JUMP_HEIGHT = 30 # 점프 높이 (px)
|
||||
JUMP_DURATION_MS = 300
|
||||
SHAKE_OFFSET = 10 # 흔들기 좌우 폭 (px)
|
||||
SHAKE_DURATION_MS = 400
|
||||
|
||||
# 에셋 경로
|
||||
CHARACTER_DIR = "assets/characters"
|
||||
DEFAULT_CHARACTER = "박뚱냥.png"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
PyQt5>=5.15,<6.0
|
||||
```
|
||||
|
||||
개발 시 추가:
|
||||
```
|
||||
pyinstaller>=6.0 # .exe 패킹용 (나중에)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Windows 전용**: PyQt5 투명 윈도우는 Windows에서 가장 안정적. macOS/Linux는 고려하지 않음.
|
||||
- **이미지 1장으로 시작**: 현재 박뚱냥.png 정면 포즈 1장. 시선은 이미지 기울기 + 좌우 반전으로 표현.
|
||||
- **NAS 배포 불필요**: Docker, docker-compose.yml, deploy.sh 수정 없음.
|
||||
- **독립 프로젝트**: `C:\Users\jaeoh\Desktop\workspace\pet-lab`에 별도 Git 저장소.
|
||||
|
||||
---
|
||||
|
||||
## Future Extensions
|
||||
|
||||
- 스프라이트 시트 추가: idle, walk, sit, sleep 등 포즈별 이미지 → 상태 머신 기반 애니메이션
|
||||
- 자율 행동: 일정 시간 마우스 비활동 시 졸기/잠자기 상태 전환
|
||||
- 시스템 트레이 아이콘: 종료/설정 접근
|
||||
- 설정 파일 저장/로드: JSON으로 크기/위치/캐릭터 선택 영속화
|
||||
- 다중 캐릭터: `assets/characters/` 디렉토리에 여러 캐릭터 추가, 우클릭 메뉴에서 선택
|
||||
- PyInstaller .exe 패킹: 단독 배포용 실행파일 생성
|
||||
- 웹 서비스 연동: pet-lab API 서버 → 캐릭터 다운로드/공유
|
||||
@@ -0,0 +1,471 @@
|
||||
# packs-lab 인프라 통합 + admin mint-token 설계
|
||||
|
||||
> 대상: `web-backend/packs-lab/`
|
||||
> 외부 의존: Supabase(`pack_files` 테이블) + Vercel SaaS(HMAC 호출자)
|
||||
> 후속 별도 스펙: Vercel-side admin UI / 사용자 다운로드 / cleanup cron / multi-admin
|
||||
|
||||
---
|
||||
|
||||
## 1. 목표
|
||||
|
||||
`packs-lab`은 NAS 자료 다운로드 자동화 백엔드. Synology DSM 공유 링크 발급 + 5GB 멀티파트 업로드 수신을 담당하고, Vercel SaaS와 HMAC으로 통신한다. 사용자 인증은 Vercel이 Supabase로 처리하고 본 서비스는 외부 인증을 다루지 않는다.
|
||||
|
||||
이미 코드(HMAC 미들웨어 / DSM client / 4 라우트)는 작성되어 있으나 인프라 통합 + Supabase 스키마 + admin upload 토큰 발급 흐름이 빠져 있어 운영 가능 상태가 아니다. 본 스펙은 그 갭을 메운다.
|
||||
|
||||
### 핵심 변경
|
||||
|
||||
- **신규 라우트**: `POST /api/packs/admin/mint-token` (Vercel HMAC → 일회성 업로드 토큰)
|
||||
- **Supabase DDL**: `pack_files` 테이블 + 활성·삭제 인덱스
|
||||
- **인프라**: docker-compose `packs-lab` 서비스 등록(18950) + nginx `/api/packs/` 5GB 통과 + `.env.example` 6+1 환경변수
|
||||
- **테스트**: routes 통합 + DSM client mock
|
||||
- **문서**: web-backend / workspace CLAUDE.md 5곳 갱신
|
||||
- **DELETE 라우트 docstring**: "DSM 공유 정리" 표현을 "DSM 공유 자동 만료"로 수정 (실제 동작과 일치)
|
||||
|
||||
### 변경하지 않는 것
|
||||
|
||||
- 기존 `auth.py` (`mint_upload_token` 그대로 활용)
|
||||
- 기존 `dsm_client.py`
|
||||
- 기존 `routes.py`의 sign-link / upload / list / delete 본문
|
||||
- DSM 공유 추적 테이블 — 4시간 자동 만료로 충분(브레인스토밍 결정)
|
||||
|
||||
---
|
||||
|
||||
## 2. 컴포넌트 + 통신 흐름
|
||||
|
||||
### 2.1 변경 받는 파일
|
||||
|
||||
| 영역 | 파일 | 변경 |
|
||||
|------|------|------|
|
||||
| 백엔드 | `packs-lab/app/routes.py` | DELETE docstring 수정 + admin mint-token 라우트 추가 |
|
||||
| 백엔드 | `packs-lab/app/models.py` | `MintTokenRequest`, `MintTokenResponse` 스키마 추가 |
|
||||
| 백엔드 | `packs-lab/app/auth.py` | 변경 없음 (기존 `mint_upload_token` 활용) |
|
||||
| 테스트 | `packs-lab/tests/conftest.py` (신규) | autouse `BACKEND_HMAC_SECRET` 셋팅 |
|
||||
| 테스트 | `packs-lab/tests/test_routes.py` (신규) | 5 라우트 통합 테스트 |
|
||||
| 테스트 | `packs-lab/tests/test_dsm_client.py` (신규) | DSM 7.x API mock 테스트 |
|
||||
| DB | `packs-lab/supabase/pack_files.sql` (신규) | DDL + 인덱스 |
|
||||
| 인프라 | `docker-compose.yml` | `packs-lab` 서비스 추가 |
|
||||
| 인프라 | `nginx/default.conf` | `/api/packs/` 라우팅 (`client_max_body_size 5G` + streaming) |
|
||||
| 인프라 | `.env.example` | 6+1 신규 환경변수 |
|
||||
| 문서 | `web-backend/CLAUDE.md` | 1·4·5·8·9 섹션 갱신 |
|
||||
| 문서 | `workspace/CLAUDE.md` | 컨테이너 표 한 줄 추가 |
|
||||
|
||||
### 2.2 통신 흐름
|
||||
|
||||
**ADMIN 업로드**
|
||||
|
||||
```
|
||||
Vercel admin UI ─────→ Vercel API (HMAC 헤더 추가)
|
||||
│
|
||||
▼
|
||||
POST /api/packs/admin/mint-token
|
||||
│
|
||||
backend: verify_request_hmac
|
||||
│
|
||||
mint_upload_token({tier, label, filename, size_bytes, jti, expires_at})
|
||||
│
|
||||
Vercel ←─────────────── token ──────┘
|
||||
│
|
||||
▼
|
||||
admin browser → POST /api/packs/upload
|
||||
Authorization: Bearer <token>
|
||||
multipart body (≤5GB)
|
||||
│
|
||||
backend: verify_upload_token + JTI mark
|
||||
│
|
||||
파일 저장 (PACK_BASE_DIR/{filename}, 평면 구조 — tier는 filename 규칙으로 구분)
|
||||
│
|
||||
Supabase INSERT pack_files
|
||||
```
|
||||
|
||||
**사용자 다운로드**
|
||||
|
||||
```
|
||||
사용자 → Vercel SaaS (Supabase auth + tier·결제 검증)
|
||||
│
|
||||
▼
|
||||
POST /api/packs/sign-link (HMAC + file_path)
|
||||
│
|
||||
backend: verify_request_hmac
|
||||
│
|
||||
DSM Sharing.create (4시간 만료)
|
||||
│
|
||||
사용자 ← Vercel ← 다운로드 URL (4시간 유효)
|
||||
```
|
||||
|
||||
### 2.3 기각된 대안
|
||||
|
||||
| 대안 | 기각 사유 |
|
||||
|------|-----------|
|
||||
| Vercel-side 토큰 발급 | 토큰 포맷 양쪽 분산, 변경 시 동기화 부담 |
|
||||
| admin browser → backend 직접 HMAC | admin browser에 secret 노출, 보안 약화 |
|
||||
| DSM 공유 추적 테이블 | 4시간 자동 만료로 충분, YAGNI |
|
||||
| Resumable multipart upload | 5GB는 단일 stream으로 충분, 복잡도 증가 |
|
||||
| `pack_files.min_tier`를 PostgreSQL ENUM | tier 추가 시 ALTER TYPE 번거로움. text+CHECK 채택 |
|
||||
|
||||
---
|
||||
|
||||
## 3. `POST /api/packs/admin/mint-token`
|
||||
|
||||
### 3.1 Pydantic 스키마 (`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
|
||||
```
|
||||
|
||||
### 3.2 라우트 본문 (`routes.py` 추가)
|
||||
|
||||
```python
|
||||
import time, uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .auth import mint_upload_token, verify_request_hmac
|
||||
from .models import MintTokenRequest, MintTokenResponse
|
||||
|
||||
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
|
||||
|
||||
@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) # upload 라우트와 동일 검증
|
||||
|
||||
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,
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 결정 근거
|
||||
|
||||
| 항목 | 값 | 근거 |
|
||||
|------|-----|------|
|
||||
| TTL default | 1800s (30분) | 5GB 업로드 시작 + 진행 시간 여유. 1Gbps에서 약 40s, 50Mbps에서 약 14분 |
|
||||
| TTL env override | `UPLOAD_TOKEN_TTL_SEC` | 운영 중 조정 가능 |
|
||||
| filename 검증 | upload와 동일 (`_check_filename`) | 토큰 발급 시점에 미리 거부 → admin UI 즉시 피드백 |
|
||||
| jti 응답 포함 | yes | admin이 업로드 결과 추적용 |
|
||||
| Vercel ↔ backend | HMAC (`X-Timestamp` + `X-Signature`) | 다른 admin 라우트와 동일 패턴 |
|
||||
| admin browser ↔ backend | Bearer token (단발성 jti) | 기존 upload 라우트 그대로 |
|
||||
|
||||
### 3.4 DELETE 라우트 docstring 수정
|
||||
|
||||
`routes.py` 모듈 docstring에서:
|
||||
|
||||
```diff
|
||||
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete + DSM 공유 정리
|
||||
+ DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
|
||||
```
|
||||
|
||||
`delete_file` 함수에는 변경 없음.
|
||||
|
||||
---
|
||||
|
||||
## 4. Supabase `pack_files` DDL
|
||||
|
||||
**파일**: `packs-lab/supabase/pack_files.sql` (신규, 운영 배포 시 Supabase SQL editor에서 실행)
|
||||
|
||||
```sql
|
||||
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
|
||||
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, -- NAS 절대경로, 동일 경로 중복 방지
|
||||
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;
|
||||
```
|
||||
|
||||
### 4.1 필드 결정 근거
|
||||
|
||||
| 필드 | 타입 / 제약 | 근거 |
|
||||
|------|------------|------|
|
||||
| `id` | uuid PK + `gen_random_uuid()` default | routes.py가 client-side `uuid.uuid4()` 생성하지만 default도 둬 fallback |
|
||||
| `min_tier` | text + CHECK | enum 대신 text+CHECK가 PostgreSQL에서 더 유연 |
|
||||
| `file_path` | text NOT NULL UNIQUE | 같은 tier/filename 충돌은 파일시스템에서 잡지만 DB 레벨도 보강 |
|
||||
| `size_bytes` | bigint + CHECK > 0 | 5GB는 int 범위 안이지만 미래 대비 bigint |
|
||||
| `sort_order` | int NOT NULL default 0 | routes INSERT가 sort_order 미지정 → 0 기본 |
|
||||
| `uploaded_at` | timestamptz default now() | routes 코드가 `res.data[0]["uploaded_at"]` 그대로 응답에 사용 — DB가 채워줌 |
|
||||
| `deleted_at` | nullable | soft delete |
|
||||
|
||||
### 4.2 RLS
|
||||
|
||||
비활성. backend가 `service_role` key 사용하므로 RLS 우회. Vercel/사용자 직접 접근 없음 → unsafe 아님.
|
||||
|
||||
---
|
||||
|
||||
## 5. 인프라 통합
|
||||
|
||||
### 5.1 `docker-compose.yml` — `packs-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}
|
||||
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}
|
||||
```
|
||||
|
||||
| 결정 | 값 | 근거 |
|
||||
|------|-----|------|
|
||||
| 포트 | 18950 | 18800(realestate) → 18900(agent-office) → 18950(packs) 순차 |
|
||||
| `PACK_BASE_DIR` (컨테이너 내부) | `/app/data/packs` | routes.py upload target. docker-compose volume 우측. |
|
||||
| `PACK_HOST_DIR` (NAS 호스트) | 운영 `/volume1/docker/webpage/media/packs` / 로컬 fallback `./data/packs` | DSM·Supabase에 노출되는 절대경로. routes.py가 file_path로 저장. 미설정 시 `PACK_BASE_DIR`로 fallback. |
|
||||
| `PACK_DATA_PATH` (호스트 마운트) | default `./data/packs` (로컬), NAS `/volume1/docker/webpage/media/packs` | docker-compose volume 좌측만 사용 |
|
||||
|
||||
### 5.2 `nginx/default.conf` — `/api/packs/` 라우팅
|
||||
|
||||
```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; # 스트리밍 통과 (메모리/디스크 buffer 회피)
|
||||
proxy_read_timeout 1800s;
|
||||
proxy_send_timeout 1800s;
|
||||
}
|
||||
```
|
||||
|
||||
| 결정 | 근거 |
|
||||
|------|------|
|
||||
| `client_max_body_size 5G` | 라우트 단위 — 다른 location은 default 유지 |
|
||||
| `proxy_request_buffering off` | 5GB 파일을 nginx가 모두 받고 backend에 forward하면 ~5GB 디스크 buffer 발생 |
|
||||
| `proxy_read/send_timeout 1800s` | 30분 — 업로드 토큰 TTL과 일치, 느린 업링크에서 5GB 전송 여유 |
|
||||
|
||||
### 5.3 `.env.example` — 신규 환경변수 (7 + 3 path)
|
||||
|
||||
```bash
|
||||
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
|
||||
# Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||
DSM_HOST=https://gahusb.synology.me:5001
|
||||
DSM_USER=
|
||||
DSM_PASS=
|
||||
# LAN IP + self-signed cert 환경에서 IP mismatch 시 false (LAN 내부 통신이라 허용)
|
||||
DSM_VERIFY_SSL=false
|
||||
|
||||
# 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
|
||||
|
||||
# 컨테이너 내부 저장 경로 (routes.py upload target. docker-compose volume 우측)
|
||||
PACK_BASE_DIR=/app/data/packs
|
||||
|
||||
# DSM API용 path. Synology DSM API는 일반 사용자 권한일 때 /<shared_folder>/... 형식만 인식하고 /volume1/... 절대경로는 거부(error 408).
|
||||
# 운영 NAS는 반드시 shared folder 시점 — /docker/webpage/media/packs.
|
||||
# admin 사용자는 /volume1/... 도 가능하지만 보안상 별도 packs-bot user 권장.
|
||||
PACK_HOST_DIR=/docker/webpage/media/packs
|
||||
```
|
||||
|
||||
### 5.4 NAS 디렉토리 준비
|
||||
|
||||
운영 첫 배포 시 SSH로 1회. 파일은 `PACK_HOST_DIR` 평면에 직접 저장 — tier 디렉토리 분기는 만들지 않음(tier 구분은 filename 규칙으로 admin이 관리):
|
||||
|
||||
```bash
|
||||
mkdir -p /volume1/docker/webpage/media/packs # 호스트 OS path (volume 마운트용)
|
||||
chown -R PUID:PGID /volume1/docker/webpage/media/packs
|
||||
```
|
||||
|
||||
PUID/PGID는 `.env`의 기존 값 사용.
|
||||
|
||||
> ⚠️ **DSM 사용자 권한 — File Station + Sharing 둘 다 필요**: Control Panel → User → packs-bot(또는 admin) → Permissions → File Station에서 `docker` shared folder Read 권한 + Applications → Sharing 권한 ON.
|
||||
|
||||
### 5.5 `scripts/deploy-nas.sh` SERVICES 화이트리스트
|
||||
|
||||
webhook 자동 배포(deployer)가 호출하는 sync 스크립트는 화이트리스트로 동기화 대상 디렉토리를 명시한다. 신규 서비스 추가 시 반드시 함께 수정해야 NAS 운영 디렉토리에 소스 sync + docker compose 빌드가 동작한다.
|
||||
|
||||
```bash
|
||||
SERVICES="lotto travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab nginx scripts"
|
||||
```
|
||||
|
||||
(packs-lab 누락 시 `docker compose ps`에 packs-lab 미등장 — 첫 배포 시 가장 흔한 누락 항목)
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 전략
|
||||
|
||||
기존 `tests/test_auth.py` 유지. 신규 3 파일.
|
||||
|
||||
### 6.1 `tests/conftest.py` (신규)
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hmac_secret(monkeypatch):
|
||||
"""모든 테스트에서 동일한 HMAC secret 사용."""
|
||||
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
|
||||
```
|
||||
|
||||
### 6.2 `tests/test_routes.py` (신규) — 통합 테스트
|
||||
|
||||
DSM·Supabase 모두 mock. `pytest`, `monkeypatch`, `unittest.mock`, `fastapi.testclient.TestClient` 사용.
|
||||
|
||||
| 테스트 | 검증 |
|
||||
|--------|------|
|
||||
| `test_sign_link_hmac_required` | timestamp/signature 헤더 누락 → 401 |
|
||||
| `test_sign_link_outside_base_dir` | file_path가 `PACK_BASE_DIR` 외부 → 400 |
|
||||
| `test_sign_link_calls_dsm` | mock된 `create_share_link` 호출 검증, URL 응답 |
|
||||
| `test_mint_token_hmac_required` | HMAC 누락 → 401 |
|
||||
| `test_mint_token_returns_valid_token` | 발급된 token이 `verify_upload_token`으로 통과 |
|
||||
| `test_mint_token_invalid_filename` | 확장자 미허용 → 400 |
|
||||
| `test_upload_token_required` | Authorization Bearer 누락 → 401 |
|
||||
| `test_upload_size_mismatch` | 토큰 size_bytes ≠ 실제 → 400 |
|
||||
| `test_upload_jti_replay` | 같은 토큰 두 번 → 두 번째 409 |
|
||||
| `test_list_returns_active_only` | mock supabase 응답에서 deleted_at NULL만 반환 |
|
||||
| `test_delete_soft_deletes` | mock supabase update에 deleted_at ISO timestamp 들어감 |
|
||||
|
||||
### 6.3 `tests/test_dsm_client.py` (신규)
|
||||
|
||||
httpx mock(`respx` 또는 `MockTransport`) 또는 `monkeypatch.setattr` 패치.
|
||||
|
||||
| 테스트 | 검증 |
|
||||
|--------|------|
|
||||
| `test_create_share_link_login_logout` | login → Sharing.create → logout 순서 |
|
||||
| `test_create_share_link_returns_url_and_expiry` | 응답 파싱 |
|
||||
| `test_dsm_login_failure_raises` | login API success=false → DSMError |
|
||||
| `test_dsm_share_failure_logs_out` | Sharing.create 실패해도 logout 호출 (try/finally) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 문서 갱신
|
||||
|
||||
### 7.1 `web-backend/CLAUDE.md` — 5곳
|
||||
|
||||
**1. 1.프로젝트 개요**
|
||||
|
||||
```diff
|
||||
- 서비스: 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개)
|
||||
```
|
||||
|
||||
**2. 4.Docker 서비스 표** — 신규 행
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
**3. 5.Nginx 라우팅 표** — 신규 행
|
||||
|
||||
```
|
||||
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 (`client_max_body_size 5G` + `proxy_request_buffering off`) |
|
||||
```
|
||||
|
||||
**4. 8.로컬 개발 표** — 신규 행
|
||||
|
||||
```
|
||||
| Packs Lab | http://localhost:18950 |
|
||||
```
|
||||
|
||||
**5. 9.서비스별** — `### packs-lab (packs-lab/)` 신규 섹션
|
||||
|
||||
내용:
|
||||
- 용도 (NAS DSM 공유링크 + 5GB 업로드 + Vercel HMAC, 사용자 인증은 Vercel이 Supabase로 처리)
|
||||
- 환경변수 6+1개
|
||||
- DB는 외부 Supabase `pack_files` (DDL은 `packs-lab/supabase/pack_files.sql`)
|
||||
- 파일 구조: `main.py`, `auth.py`, `dsm_client.py`, `routes.py`, `models.py`
|
||||
- API 표 5개:
|
||||
- `POST /api/packs/sign-link` (Vercel HMAC → DSM Sharing.create)
|
||||
- `POST /api/packs/admin/mint-token` (Vercel HMAC → upload 토큰)
|
||||
- `POST /api/packs/upload` (Bearer token → multipart 5GB)
|
||||
- `GET /api/packs/list` (Vercel HMAC → 활성 파일 목록)
|
||||
- `DELETE /api/packs/{file_id}` (Vercel HMAC → soft delete)
|
||||
|
||||
### 7.2 `workspace/CLAUDE.md`
|
||||
|
||||
컨테이너 표에 한 줄 추가:
|
||||
|
||||
```
|
||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 스코프
|
||||
|
||||
### 본 spec 범위
|
||||
|
||||
- ✅ admin mint-token 라우트 신설
|
||||
- ✅ Supabase `pack_files` DDL
|
||||
- ✅ docker-compose / nginx / .env.example / NAS 디렉토리 마운트
|
||||
- ✅ tests (auth 유지 + routes 통합 + dsm_client mock)
|
||||
- ✅ CLAUDE.md 2곳 갱신
|
||||
- ✅ DELETE 라우트 docstring 수정
|
||||
|
||||
### 후속 별도 spec
|
||||
|
||||
- ❌ Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase pricing & user 테이블
|
||||
- ❌ DSM 공유 추적 (즉시 차단 필요시)
|
||||
- ❌ deleted_at + N일 후 실제 파일 삭제 cron
|
||||
- ❌ multi-admin 토큰 발급 권한 분리
|
||||
- ❌ resumable multipart 업로드 (5GB tus 등)
|
||||
- ❌ pack_files sort_order 편집 endpoint (admin UI 단계)
|
||||
- ❌ monitoring (업로드 실패율, DSM API latency)
|
||||
@@ -0,0 +1,519 @@
|
||||
# Music YouTube 파이프라인 — 단계별 승인 자동화 설계
|
||||
|
||||
> 작성일: 2026-05-07
|
||||
> 상태: 설계 승인 대기
|
||||
> 관련 후속 작업: STATUS.md 2-3, 2-4
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경
|
||||
|
||||
현재 Music YouTube 탭에는 영상 제작 / 수익 추적 / 시장 트렌드 / 컴파일 4개 서브탭이 있고, music-lab 백엔드는 video_producer로 로컬 영상(MP4)까지 만들 수 있다. 그러나 **YouTube 자동 업로드와 AI 커버·메타데이터 자동 생성, AI 검토는 없다.** 트랙 생성부터 발행까지 한 편 완성하려면 매번 수동으로 영상 만들고 직접 YouTube Studio에 업로드해야 한다.
|
||||
|
||||
목표: **트랙을 골라 한 번 시작하면 단계별로 텔레그램 승인을 받으며 영상이 발행되는 파이프라인**을 구축한다. 사용자는 각 단계 산출물을 텔레그램에서 승인/반려할 수 있고, 반려 시 자연어 피드백으로 같은 단계가 재생성된다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 비목표 (Out of scope)
|
||||
|
||||
- 가사 자막 영상 (synced lyrics → 영상) — 차후
|
||||
- YouTube Shorts 전용 워크플로 (1080×1920) — 비주얼 기본값에 옵션만 두고, 실제 Shorts 최적화(60초 클립 추출 등)는 차후
|
||||
- 멀티 채널 운영 — 단일 채널 OAuth 1행만 지원
|
||||
- 비디오 편집기 UI — 트림/페이드 등은 컴파일 탭에 있고 본 파이프라인은 단일 트랙 1개 영상 가정
|
||||
|
||||
---
|
||||
|
||||
## 3. 사용자 흐름
|
||||
|
||||
```
|
||||
[사용자가 진행 시작]
|
||||
Library 트랙 카드 → "🎬 영상 파이프라인" 또는 진행 탭 → "+ 새 파이프라인"
|
||||
↓
|
||||
step 2: AI 커버 아트 생성 → 텔레그램 알림 "커버 승인?"
|
||||
step 3: 영상 비주얼 생성 (커버 + 음원) → 텔레그램 알림
|
||||
step 4: 썸네일 생성 → 텔레그램 알림
|
||||
step 5: 메타데이터 생성 → 텔레그램 알림
|
||||
↓
|
||||
AI 최종 검토 (자동, 4축 검사) → 텔레그램에 점수 + 발행 요청
|
||||
↓
|
||||
[사용자 발행 승인]
|
||||
step 6: YouTube 업로드 (private/public 정책에 따라)
|
||||
step 7: 발행 후 추적 시작 (수익 추적 탭에 표시)
|
||||
```
|
||||
|
||||
각 단계 텔레그램 알림에 사용자가 자연어로 응답한다.
|
||||
- 승인: "승인" / "시작" / "진행" / "OK" / "Agree" / "네" / "예" / "좋아"
|
||||
- 반려: "반려" / "거절" / "취소" / "no" + 수정 방향 텍스트 (예: "썸네일 색 더 어둡게")
|
||||
|
||||
---
|
||||
|
||||
## 4. 아키텍처
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (web-ui) │
|
||||
│ /lab/music → MusicStudio → YouTube 탭 │
|
||||
│ ├─ 영상 제작 (기존) │
|
||||
│ ├─ 수익 추적 (기존) │
|
||||
│ ├─ 시장 트렌드 (기존) │
|
||||
│ ├─ 컴파일 (기존) │
|
||||
│ ├─ 진행 (NEW) ← 파이프라인 카드 보드 │
|
||||
│ └─ 구성 (NEW) ← 설정 허브 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
↓ /api/music/pipeline/* (REST)
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ music-lab (FastAPI, 18600) │
|
||||
│ • 파이프라인 CRUD + 상태 머신 │
|
||||
│ • AI 커버 (DALL·E 3) — 비동기 BackgroundTask │
|
||||
│ • 영상 비주얼 (FFmpeg, 기존 video_producer 확장) │
|
||||
│ • 썸네일 (FFmpeg + 텍스트 오버레이) │
|
||||
│ • 메타데이터 생성 (Claude Haiku) │
|
||||
│ • AI 최종 검토 (Claude Sonnet, 4축 가중) │
|
||||
│ • YouTube 업로드 (google-api-python-client) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
↑ poll (30s) / push 결과
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ agent-office (FastAPI + Telegram, 18900) │
|
||||
│ • youtube_publisher 에이전트 (NEW) — 오케스트레이터 │
|
||||
│ • 단계 *_pending 진입 감지 → 텔레그램 알림 발송 │
|
||||
│ • 텔레그램 reply 자연어 의도 분류 (Claude or 화이트리스트) │
|
||||
│ • music-lab /feedback 호출 → 다음 단계 또는 재생성 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**책임 경계**:
|
||||
- **music-lab**: 무엇을 만들지 안다. 산출물 생성·저장·상태 전이.
|
||||
- **agent-office**: 언제 다음으로 넘길지 결정. 텔레그램 단일 채널 인터페이스.
|
||||
- **frontend**: 진행 상태 조회 + 사용자 트리거(시작/취소/수동 발행).
|
||||
|
||||
---
|
||||
|
||||
## 5. 상태 머신
|
||||
|
||||
```
|
||||
created
|
||||
→ cover_pending (자동 생성 후 진입)
|
||||
→ cover_approved (승인)
|
||||
→ video_pending
|
||||
→ video_approved
|
||||
→ thumb_pending
|
||||
→ thumb_approved
|
||||
→ meta_pending
|
||||
→ meta_approved
|
||||
→ ai_review (자동, 사용자 액션 X)
|
||||
→ publish_pending (검토 결과 + 발행 요청 텔레그램)
|
||||
→ publishing (업로드 중)
|
||||
→ published (완료)
|
||||
|
||||
어디서나:
|
||||
→ cancelled (사용자 취소)
|
||||
→ failed (복구 불가 오류)
|
||||
→ awaiting_manual (재생성 5회 한도 초과)
|
||||
```
|
||||
|
||||
각 `*_pending` 진입 시 → 텔레그램 알림.
|
||||
각 `*_approved` 진입 시 → 다음 단계 BackgroundTask 시작.
|
||||
|
||||
---
|
||||
|
||||
## 6. 프론트엔드 상세
|
||||
|
||||
### 6-1. 새 탭 — 구성 (`SetupTab.jsx`)
|
||||
|
||||
세로 카드 형식, 카드별 저장 버튼:
|
||||
|
||||
| 카드 | 필드 |
|
||||
|------|------|
|
||||
| YouTube 채널 연동 | OAuth 시작 → Google 인증 → 채널명·아바타 표시. 재인증 / 연결 해제 |
|
||||
| Telegram 알림 채널 | 현재 chat_id (read-only, ENV 출처). 테스트 메시지 발송 |
|
||||
| 메타데이터 템플릿 | 제목 패턴 (`[{genre}] {title} \| {bpm}BPM Lo-fi Mix` 등), 설명 multiline, 태그 CSV, 카테고리 |
|
||||
| AI 커버 아트 prompt | 장르별 prompt 템플릿 (lo-fi/phonk/ambient/pop/...) 추가/편집/삭제 |
|
||||
| AI 최종 검토 기준 | 4축 가중치 슬라이더 + pass score 임계값 (기본 60) |
|
||||
| 영상 비주얼 기본값 | 해상도 (1920×1080 / 1080×1920), 스타일 (visualizer/슬라이드쇼), 배경 (AI 커버/그라데이션) |
|
||||
| 발행 정책 | 즉시 / 예약 시간대 / privacy (private 우선) |
|
||||
|
||||
### 6-2. 새 탭 — 진행 (`PipelineTab.jsx`)
|
||||
|
||||
**상단**: "+ 새 파이프라인 시작" 버튼 → Library 트랙 선택 모달.
|
||||
|
||||
**카드 그리드** — 진행 중 + 완료/실패/취소 (필터 토글):
|
||||
|
||||
```
|
||||
┌─ Track Title (genre · BPM) ───────────── [Cancel] ─┐
|
||||
│ ●━━━━━●━━━━━●━━━━━○━━━━━○━━━━━○ (6단계 진행 바) │
|
||||
│ 커버 영상 썸네 메타 검토 발행 │
|
||||
│ │
|
||||
│ 현재: [메타데이터 승인 대기] │
|
||||
│ 텔레그램에 알림 보냄 — 12분 전 │
|
||||
│ │
|
||||
│ [최근 산출물 미리보기] │
|
||||
│ • 메타: "[Lo-fi] Midnight Drive | 85BPM..." │
|
||||
│ • 썸네일: ▭ │
|
||||
│ │
|
||||
│ 📜 피드백 히스토리 │
|
||||
│ • "썸네일 색이 너무 어두워" → 재생성 (5분 전) │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**상태 시각**:
|
||||
- `running` — 스피너 + "처리 중..."
|
||||
- `awaiting_approval` — 점멸 도트 + "텔레그램 응답 대기"
|
||||
- `regenerating` — 회전 화살표 + "피드백 반영 중"
|
||||
- `completed` — 체크 + YouTube 링크
|
||||
- `failed` / `awaiting_manual` — 빨간 배지 + 사유
|
||||
|
||||
**폴링**: 카드 보일 때 5초 간격 `GET /api/music/pipeline?status=active`.
|
||||
|
||||
### 6-3. 영상 제작 탭 (기존)
|
||||
|
||||
그대로 유지. footer에 "💡 단계별 자동화는 진행 탭에서" 1줄 안내.
|
||||
|
||||
### 6-4. Library 카드 변경
|
||||
|
||||
기존 액션 옆에 "🎬 영상 파이프라인" 버튼 추가 → 클릭 시 신규 파이프라인 생성 후 진행 탭 이동.
|
||||
|
||||
---
|
||||
|
||||
## 7. 백엔드 상세
|
||||
|
||||
### 7-1. music-lab 신규 모듈
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `app/pipeline/state_machine.py` | 상태 전이 + 검증 |
|
||||
| `app/pipeline/orchestrator.py` | `start_step(pipeline_id, step)` — BackgroundTask 등록 |
|
||||
| `app/pipeline/cover.py` | DALL·E 3 호출 + 폴백 |
|
||||
| `app/pipeline/metadata.py` | Claude Haiku 호출 + 템플릿 치환 |
|
||||
| `app/pipeline/review.py` | Claude Sonnet 4축 검토 + 가중평균 |
|
||||
| `app/pipeline/youtube.py` | OAuth + 업로드 (google-api-python-client) |
|
||||
| `app/pipeline/storage.py` | `/data/videos/{id}/` 산출물 관리 |
|
||||
|
||||
기존 `app/video_producer.py`는 `app/pipeline/video.py`로 이동 + 슬라이드쇼 입력으로 AI 커버 사용 옵션 추가.
|
||||
|
||||
### 7-2. agent-office 신규/변경
|
||||
|
||||
| 파일 | 변경 |
|
||||
|------|------|
|
||||
| `app/agents/youtube_publisher.py` | NEW — 오케스트레이터 |
|
||||
| `app/scheduler.py` | 30초 간격 `_poll_pipelines` 잡 추가 |
|
||||
| `app/telegram/conversational.py` | reply 매칭 + youtube_publisher로 라우팅 |
|
||||
| `app/service_proxy.py` | music-lab pipeline 호출 헬퍼 추가 |
|
||||
|
||||
`youtube_publisher`:
|
||||
- `poll_state_changes()` — music-lab `/api/music/pipeline?status=active` 폴링, `*_pending` 신규 진입 시 텔레그램 발송. 멱등 처리(메시지 ID 저장).
|
||||
- `on_telegram_reply(message)` — `reply_to_message_id`로 pipeline 매칭, 자연어 분류 → `/feedback` 호출.
|
||||
|
||||
### 7-3. 자연어 의도 분류
|
||||
|
||||
```python
|
||||
APPROVE_WORDS = {"승인", "시작", "진행", "ok", "okay", "agree", "네", "예", "좋아", "go"}
|
||||
REJECT_WORDS = {"반려", "거절", "취소", "no", "nope"}
|
||||
|
||||
def classify_intent(text: str) -> tuple[str, str | None]:
|
||||
t = text.strip().lower()
|
||||
# 1. 명확한 단어만 — LLM 우회
|
||||
if t in APPROVE_WORDS:
|
||||
return ("approve", None)
|
||||
if t in REJECT_WORDS:
|
||||
return ("reject", None)
|
||||
# 2. 반려 단어 + 추가 텍스트 — 단순 분리
|
||||
for w in REJECT_WORDS:
|
||||
if t.startswith(w):
|
||||
return ("reject", text[len(w):].strip(" ,.-:"))
|
||||
# 3. 모호한 경우 — Claude Haiku 호출
|
||||
return _llm_classify(text)
|
||||
```
|
||||
|
||||
LLM 분류 응답 (JSON):
|
||||
```json
|
||||
{"intent": "approve|reject|unclear", "feedback": "..."}
|
||||
```
|
||||
|
||||
`unclear` → 텔레그램에 "다시 입력해주세요. 예: '승인' 또는 '제목을 짧게'" 안내 + 같은 상태 유지.
|
||||
|
||||
### 7-4. AI 최종 검토 (4축)
|
||||
|
||||
`meta_approved` 직후 자동 진행. Claude Sonnet 1회 호출.
|
||||
|
||||
입력:
|
||||
- 트랙 정보 (title, genre, BPM, key, scale, moods, instruments)
|
||||
- 영상 정보 (length, resolution, style)
|
||||
- 메타데이터 (title, description, tags, category)
|
||||
- 썸네일 URL
|
||||
- 트렌드 데이터 (`market_trends` top 10)
|
||||
|
||||
출력 JSON:
|
||||
```json
|
||||
{
|
||||
"metadata_quality": {"score": 0-100, "notes": "..."},
|
||||
"policy_compliance": {"score": 0-100, "issues": []},
|
||||
"viewer_experience": {"score": 0-100, "notes": "..."},
|
||||
"trend_alignment": {"score": 0-100, "matched_keywords": []},
|
||||
"weighted_total": 0-100,
|
||||
"verdict": "pass" | "fail",
|
||||
"summary": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**가중치 (기본, 구성 탭에서 조정 가능)**:
|
||||
- 메타데이터 품질 25
|
||||
- 콘텐츠 정책 30
|
||||
- 시청 경험 25
|
||||
- 트렌드 정렬 20
|
||||
|
||||
**임계값 60 미만 → `fail`**. 텔레그램 메시지에 "강제 발행" / "메타로 돌아가 재검토" 안내.
|
||||
|
||||
### 7-5. AI 커버 아트
|
||||
|
||||
- 모델: OpenAI `gpt-image-1` (DALL·E 3 후속)
|
||||
- 해상도: 1024×1024
|
||||
- 환경변수: `OPENAI_API_KEY`
|
||||
- 비용: 1024×1024 standard ≈ $0.04/장 (단계당 최대 5회 = $0.20)
|
||||
- 폴백: 그라데이션 (`GENRE_COLORS`) + 트랙 제목 텍스트 오버레이
|
||||
|
||||
prompt 빌더 (구성 탭의 장르별 템플릿 사용):
|
||||
```
|
||||
{genre_template}, {mood_descriptor}, no text, high quality
|
||||
```
|
||||
|
||||
### 7-6. 메타데이터 자동 생성
|
||||
|
||||
- 모델: Claude Haiku
|
||||
- 호출 시점: `meta_pending` 진입 시 (커버 승인 후 미리 생성하지 않음)
|
||||
- 입력: 트랙 정보 + 구성 탭 메타 템플릿 + 트렌드 키워드
|
||||
- 출력: title (60자 이내), description (3-5문단, 1000자 이내), tags (15개 이내), category_id
|
||||
|
||||
### 7-7. YouTube 업로드
|
||||
|
||||
- 라이브러리: `google-api-python-client` + `google-auth-oauthlib`
|
||||
- OAuth flow: Authorization Code → refresh_token 저장 (`youtube_oauth_tokens` 테이블)
|
||||
- 업로드 시 access_token 갱신 → resumable upload
|
||||
- Privacy: 구성 탭 정책 (private/unlisted/public)
|
||||
- 카테고리: 메타데이터의 category_id (기본 10 = Music)
|
||||
|
||||
---
|
||||
|
||||
## 8. 데이터 모델
|
||||
|
||||
### 8-1. 신규 테이블 (music-lab `db.py`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE video_pipelines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
track_id INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
state_started_at TEXT NOT NULL,
|
||||
cover_url TEXT,
|
||||
video_url TEXT,
|
||||
thumbnail_url TEXT,
|
||||
metadata_json TEXT,
|
||||
review_json TEXT,
|
||||
youtube_video_id TEXT,
|
||||
feedback_count_per_step TEXT NOT NULL DEFAULT '{}',
|
||||
last_telegram_msg_ids TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
cancelled_at TEXT,
|
||||
failed_reason TEXT,
|
||||
FOREIGN KEY (track_id) REFERENCES tracks(id)
|
||||
);
|
||||
|
||||
CREATE TABLE pipeline_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pipeline_id INTEGER NOT NULL,
|
||||
step TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
FOREIGN KEY (pipeline_id) REFERENCES video_pipelines(id)
|
||||
);
|
||||
|
||||
CREATE TABLE pipeline_feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pipeline_id INTEGER NOT NULL,
|
||||
step TEXT NOT NULL,
|
||||
feedback_text TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
FOREIGN KEY (pipeline_id) REFERENCES video_pipelines(id)
|
||||
);
|
||||
|
||||
CREATE TABLE youtube_oauth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id TEXT NOT NULL,
|
||||
channel_title TEXT,
|
||||
avatar_url TEXT,
|
||||
refresh_token TEXT NOT NULL,
|
||||
access_token TEXT,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE youtube_setup (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
metadata_template_json TEXT NOT NULL,
|
||||
cover_prompts_json TEXT NOT NULL,
|
||||
review_weights_json TEXT NOT NULL,
|
||||
review_threshold INTEGER NOT NULL DEFAULT 60,
|
||||
visual_defaults_json TEXT NOT NULL,
|
||||
publish_policy_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 8-2. 산출물 저장 경로
|
||||
|
||||
```
|
||||
/data/videos/{pipeline_id}/
|
||||
├─ cover.jpg (AI 또는 폴백)
|
||||
├─ video.mp4 (FFmpeg 결과)
|
||||
├─ thumbnail.jpg
|
||||
└─ logs/ (FFmpeg/upload 로그)
|
||||
```
|
||||
|
||||
노출 URL: `/media/videos/{pipeline_id}/<file>` (nginx 정적 서빙).
|
||||
|
||||
---
|
||||
|
||||
## 9. API 엔드포인트
|
||||
|
||||
### 9-1. music-lab 신규
|
||||
|
||||
| 메서드 | 경로 | 용도 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/music/pipeline` | 파이프라인 목록 (`?status=active|all`) |
|
||||
| GET | `/api/music/pipeline/{id}` | 단건 + jobs + feedback |
|
||||
| POST | `/api/music/pipeline` | 신규 (body: `{track_id}`) |
|
||||
| POST | `/api/music/pipeline/{id}/start` | 첫 단계 시작 → 202 |
|
||||
| POST | `/api/music/pipeline/{id}/feedback` | 승인/반려 (body: `{step, intent, feedback_text?}`) |
|
||||
| POST | `/api/music/pipeline/{id}/cancel` | 취소 |
|
||||
| POST | `/api/music/pipeline/{id}/publish` | 검토 후 업로드 트리거 |
|
||||
| GET | `/api/music/setup` | 구성 조회 |
|
||||
| PUT | `/api/music/setup` | 구성 저장 |
|
||||
| GET | `/api/music/youtube/auth-url` | OAuth 시작 URL |
|
||||
| GET | `/api/music/youtube/callback` | OAuth callback |
|
||||
| POST | `/api/music/youtube/disconnect` | 연결 해제 |
|
||||
| GET | `/api/music/youtube/status` | 연결 상태 |
|
||||
|
||||
모든 생성/처리 엔드포인트는 **즉시 202 + job_id 반환**, BackgroundTask로 처리. 프론트는 `GET /api/music/pipeline/{id}`로 폴링.
|
||||
|
||||
### 9-2. 멱등성
|
||||
|
||||
- `/feedback`은 동일 `(pipeline_id, step, intent)` 중복 호출 시 무시 (이미 다음 상태로 넘어간 경우 텔레그램 reply 지연 방지)
|
||||
- 텔레그램 메시지 ID 저장으로 동일 메시지 중복 처리 방지
|
||||
|
||||
---
|
||||
|
||||
## 10. 비동기 처리 + 폴백
|
||||
|
||||
**원칙**: 모든 AI/생성 작업은 `BackgroundTasks` + DB job 상태로 처리. 호출 즉시 202, 폴링으로 결과 확인. **사용자 경험: 어떻게든 다음 단계로 보낸다, 단 폴백 사용 시 텔레그램에 명시.**
|
||||
|
||||
| 작업 | 타임아웃 | 폴백 |
|
||||
|------|---------|------|
|
||||
| DALL·E 3 | 90초 | 그라데이션 + 텍스트 오버레이 |
|
||||
| Claude Haiku (메타) | 30초 | 템플릿 변수 그대로 치환 |
|
||||
| Claude Sonnet (검토) | 60초 | 휴리스틱만 (정책 단어 매치 + 길이 체크) |
|
||||
| FFmpeg | 5분 | `failed` + 텔레그램 알림 |
|
||||
| YouTube upload | 10분 | 재시도 3회 → `failed` |
|
||||
|
||||
각 BackgroundTask는 `pipeline_jobs`에 `running → succeeded/failed` 기록. 진행 탭은 이 정보로 카드 진행도 표시.
|
||||
|
||||
---
|
||||
|
||||
## 11. 에러 처리 매트릭스
|
||||
|
||||
| 시나리오 | 동작 |
|
||||
|---------|------|
|
||||
| OAuth refresh 실패 | 발행 단계 `failed` + 텔레그램 "재인증 필요" + 구성 탭 빨간 배지 |
|
||||
| DALL·E timeout | 폴백(그라데이션) + 텔레그램 "AI 폴백 사용됨" |
|
||||
| Claude timeout | 폴백(템플릿/휴리스틱) + 동일 표기 |
|
||||
| FFmpeg 실패 | `failed` + 텔레그램 "수동 점검 필요" + task_id |
|
||||
| YouTube quota | 24시간 후 자동 재시도 1회 → 그래도 실패 시 `failed` |
|
||||
| 텔레그램 reply 의도 `unclear` | 안내 메시지 + 같은 상태 유지 |
|
||||
| 재생성 5회 초과 | `awaiting_manual` + 텔레그램 안내 |
|
||||
| 동일 트랙 파이프라인 중복 | 409 Conflict |
|
||||
| 트랙 삭제됨 | 파이프라인 보존, 재생성 불가, 진행 탭 "트랙 누락" 배지 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 보안 / 비밀
|
||||
|
||||
- OAuth refresh_token: SQLite에 평문(현재 패턴) — 향후 Fernet 암호화 또는 OS keystore 검토. 기본은 컨테이너 파일 권한 600 + DB 읽기 deny (이미 settings.json에 `Read(**/*.db)` 차단 추가됨)
|
||||
- `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `YOUTUBE_OAUTH_CLIENT_ID/SECRET`: docker-compose env로 주입
|
||||
- 구성 탭은 인증 게이트 없음(개인 사이트 가정) — 향후 admin 게이트 필요시 personal 서비스의 `/api/profile/auth` 패턴 적용
|
||||
|
||||
---
|
||||
|
||||
## 13. 테스트 전략
|
||||
|
||||
### 13-1. 단위 테스트 (music-lab)
|
||||
|
||||
| 대상 | 테스트 |
|
||||
|------|--------|
|
||||
| `state_machine` | 정상 전이 / 잘못된 전이 거부 |
|
||||
| `feedback_handler` | approve → 다음 / reject → 동일 + feedback 저장 / 5회 초과 → awaiting_manual |
|
||||
| `cover.generate` | DALL·E mock 성공/timeout/오류 → 폴백 |
|
||||
| `metadata.generate` | Claude mock + 템플릿 치환 |
|
||||
| `review.run_4_axis` | 4축 점수 계산 + 가중평균 + verdict 임계값(60) |
|
||||
| `youtube_upload.upload` | google-api mock + 재시도 + quota 분기 |
|
||||
| OAuth | code → refresh_token, refresh 만료 시 재인증 트리거 |
|
||||
|
||||
`pytest` + `httpx_mock` + `freezegun`. 기존 music-lab 테스트 컨벤션 준수.
|
||||
|
||||
### 13-2. 단위 테스트 (agent-office)
|
||||
|
||||
| 대상 | 테스트 |
|
||||
|------|--------|
|
||||
| `classify_intent` | 화이트리스트 → LLM 미호출, 반려 단어 + 텍스트 → 분리, 모호 → LLM 호출 검증 |
|
||||
| `_poll_pipelines` | state 변경 → 텔레그램 1회만(멱등) |
|
||||
| reply 매칭 | message_id로 정확한 pipeline_id 매칭 |
|
||||
|
||||
### 13-3. 통합 테스트
|
||||
|
||||
`tests/test_pipeline_flow.py`:
|
||||
- 전체 흐름 1회: track → pipeline → 모든 단계 mock 승인 → published
|
||||
- 반려 분기: cover에서 reject + feedback → 같은 단계 재생성 → 승인 → 다음 단계
|
||||
|
||||
### 13-4. 프론트엔드 테스트
|
||||
|
||||
- `SetupTab` 폼 저장: 단순 단위 테스트 (API 인자 검증)
|
||||
- `PipelineTab` 카드 렌더링: 상태별 시각 — 빌드 + 수동 브라우저 확인
|
||||
- 폴링 로직: mock fetch + setInterval
|
||||
|
||||
기존 web-ui 패턴 (vitest 등 별도 러너 없음) 유지.
|
||||
|
||||
### 13-5. 수동 E2E 체크리스트 (출시 전)
|
||||
|
||||
- [ ] OAuth 인증 → 구성 탭 채널명 표시
|
||||
- [ ] 트랙 → 파이프라인 시작 → 텔레그램 "커버 승인" 알림
|
||||
- [ ] "승인" 답장 → 다음 단계 진행
|
||||
- [ ] "썸네일 색 어둡게" 답장 → 재생성 → 알림 재도착
|
||||
- [ ] AI 최종 검토 4축 점수 표시
|
||||
- [ ] 발행 승인 → YouTube 업로드 (private) → URL 수신
|
||||
- [ ] 24시간 후 수익 추적 탭에 신규 영상 표시
|
||||
|
||||
---
|
||||
|
||||
## 14. 마이그레이션 / 환경
|
||||
|
||||
- 신규 환경변수: `OPENAI_API_KEY`, `YOUTUBE_OAUTH_CLIENT_ID`, `YOUTUBE_OAUTH_CLIENT_SECRET`, `YOUTUBE_OAUTH_REDIRECT_URI`
|
||||
- music-lab Dockerfile: `google-api-python-client`, `google-auth-oauthlib`, `openai` 추가
|
||||
- 기존 music.db 마이그레이션: `init_db()`에 신규 테이블 5개 `CREATE IF NOT EXISTS` 추가
|
||||
- nginx 설정: `/api/music/youtube/callback` 외부 노출 필요 (OAuth redirect)
|
||||
|
||||
---
|
||||
|
||||
## 15. 산출물 / 후속
|
||||
|
||||
본 스펙은 다음 산출물을 가진다:
|
||||
- music-lab: pipeline 모듈, OAuth, 5개 테이블, 12개 엔드포인트
|
||||
- agent-office: youtube_publisher 에이전트, scheduler 폴링 잡, 자연어 분류기
|
||||
- web-ui: SetupTab, PipelineTab, Library 카드 트리거 버튼
|
||||
- 통합/단위 테스트, 수동 E2E 체크리스트
|
||||
|
||||
후속(이 스펙 외):
|
||||
- Shorts 전용 파이프라인 (60초 클립 추출 + 1080×1920)
|
||||
- 가사 자막 영상 (synced lyrics 영상화)
|
||||
- 멀티 채널 운영
|
||||
- 검토 임계값/가중치 학습 (실제 발행 후 성과 데이터 기반 자동 튜닝)
|
||||
@@ -0,0 +1,706 @@
|
||||
# Essential Mix 파이프라인 — 1시간 mix + essential 시각 스타일 + UX 강화 설계
|
||||
|
||||
> 작성일: 2026-05-09
|
||||
> 관련 spec:
|
||||
> - `2026-05-07-music-youtube-pipeline-design.md` (본 파이프라인의 베이스)
|
||||
> - `2026-05-09-gpu-video-offload-design.md` (Windows GPU 인코딩)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경
|
||||
|
||||
현재 파이프라인은 **단일 트랙 → 단일 영상**(커버 + 가장자리 파형)만 지원. 사용자는 YouTube essential 채널처럼 **1시간 이상의 음악 mix + 차분한 배경 + 중앙 비주얼라이저** 영상을 원함.
|
||||
|
||||
또한 진행 중 산출물(커버·썸네일·영상)을 NAS 파일시스템에서 직접 확인하는 게 번거로워, 진행 탭에서도 미리보기 가능했으면 함.
|
||||
|
||||
---
|
||||
|
||||
## 2. 비목표
|
||||
|
||||
- 사용자 직접 업로드 사진/영상 (P3로 미룸)
|
||||
- 360° 정확한 방사형 비주얼라이저 (ffmpeg 단독으로 한계 — `showfreqs` + ring overlay로 근사)
|
||||
- Mix 자동 큐레이션(곡 자동 선택) — 기존 컴파일 탭의 수동 선택 그대로 활용
|
||||
- AI 검토 가중치 자동 튜닝 (Mix와 단일 트랙의 다른 기준 등 — P3)
|
||||
- 텔레그램 사진 첨부 — 본 작업의 PipelineDetailModal로 우선 해결, 차후 P3
|
||||
|
||||
---
|
||||
|
||||
## 3. 사용자 흐름
|
||||
|
||||
### 3-1. Mix 영상 만들기
|
||||
|
||||
```
|
||||
[사용자] Compile 탭에서 트랙 N개 선택 → crossfade 설정 → 컴파일 시작
|
||||
→ 컴파일 완료 (1시간+ mp3 생성, 기존 흐름)
|
||||
→ 컴파일 카드에 [🎬 영상 만들기] 버튼 클릭
|
||||
→ 백엔드: POST /api/music/pipeline { compile_job_id, visual_style: 'essential' }
|
||||
→ 진행 탭으로 자동 이동, 새 카드 생성
|
||||
→ 단계별 텔레그램 승인 (기존과 동일):
|
||||
cover (또는 background_video) → video → thumbnail → metadata → AI 검토 → 발행
|
||||
→ YouTube 비공개 영상 1편
|
||||
```
|
||||
|
||||
### 3-2. 단일 트랙 영상 만들기 (기존)
|
||||
|
||||
진행 탭 모달에 라디오 "단일 트랙 / Mix" 추가. 단일 선택 시 기존 흐름 그대로.
|
||||
|
||||
### 3-3. 산출물 미리보기
|
||||
|
||||
진행 탭 카드의 cover/thumbnail 미니 썸네일 → 카드 클릭 → 상세 모달 → 큰 이미지 + 영상 플레이어 + 메타·검토 JSON.
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 모델 변경
|
||||
|
||||
### 4-1. `video_pipelines` 테이블 확장
|
||||
|
||||
신규 컬럼:
|
||||
```sql
|
||||
ALTER TABLE video_pipelines ADD COLUMN compile_job_id INTEGER NULL REFERENCES compile_jobs(id);
|
||||
ALTER TABLE video_pipelines ADD COLUMN visual_style TEXT NOT NULL DEFAULT 'essential';
|
||||
ALTER TABLE video_pipelines ADD COLUMN background_mode TEXT NOT NULL DEFAULT 'static';
|
||||
ALTER TABLE video_pipelines ADD COLUMN background_keyword TEXT;
|
||||
```
|
||||
|
||||
| 컬럼 | 의미 |
|
||||
|------|------|
|
||||
| `track_id` (기존) | 단일 트랙 입력 시 |
|
||||
| `compile_job_id` (신규) | Mix 입력 시 — `track_id` XOR `compile_job_id` |
|
||||
| `visual_style` | `single` / `essential` |
|
||||
| `background_mode` | `static` (사진) / `video_loop` (영상) |
|
||||
| `background_keyword` | Pexels 검색용 (예: "rainy window cafe"). 비어있으면 장르 기반 자동 |
|
||||
|
||||
마이그레이션: `ADD COLUMN`은 SQLite에서 안전. 기존 행은 NULL 또는 default 값 부여.
|
||||
|
||||
### 4-2. `youtube_setup.visual_defaults` JSON 확장
|
||||
|
||||
기존:
|
||||
```json
|
||||
{"resolution": "1920x1080", "style": "visualizer", "background": "ai_cover"}
|
||||
```
|
||||
|
||||
신규:
|
||||
```json
|
||||
{
|
||||
"resolution": "1920x1080",
|
||||
"default_visual_style": "essential",
|
||||
"default_background_mode": "static",
|
||||
"default_background_keyword": "",
|
||||
"background_image_source": "ai", // ai | pexels (Mix는 default ai)
|
||||
"subtitle_track_titles": true // Mix에서 곡명 자막 표시
|
||||
}
|
||||
```
|
||||
|
||||
기존 클라이언트 호환을 위해 미설정 키는 default로 fallback.
|
||||
|
||||
---
|
||||
|
||||
## 5. API 변경
|
||||
|
||||
### 5-1. `POST /api/music/pipeline` 요청 body 확장
|
||||
|
||||
```json
|
||||
{
|
||||
"track_id": 13,
|
||||
// 또는
|
||||
"compile_job_id": 5,
|
||||
// 옵션 (default는 setup에서)
|
||||
"visual_style": "essential", // single | essential
|
||||
"background_mode": "static", // static | video_loop
|
||||
"background_keyword": "rainy cafe"
|
||||
}
|
||||
```
|
||||
|
||||
검증:
|
||||
- `track_id` XOR `compile_job_id` 정확히 하나만 — 둘 다거나 둘 다 없으면 400
|
||||
- `compile_job_id`인 경우 `compile_jobs` 테이블에서 status='succeeded' 확인 — 아니면 400
|
||||
- `visual_style` 미지정 시 `youtube_setup.visual_defaults.default_visual_style`
|
||||
- `background_mode` 미지정 시 `youtube_setup.visual_defaults.default_background_mode`
|
||||
|
||||
응답:
|
||||
```json
|
||||
{
|
||||
"id": 7,
|
||||
"track_id": null,
|
||||
"compile_job_id": 5,
|
||||
"visual_style": "essential",
|
||||
"background_mode": "static",
|
||||
"state": "created",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 5-2. `GET /api/music/pipeline/{id}` 응답 확장
|
||||
|
||||
신규 필드: `compile_job_id`, `visual_style`, `background_mode`, `background_keyword`, `tracks` (Mix면 트랙 리스트, 단일이면 단일 트랙 1개)
|
||||
|
||||
`tracks` 형식:
|
||||
```json
|
||||
[
|
||||
{"id": 13, "title": "Lo-Fi Drive", "start_offset_sec": 0, "duration_sec": 176},
|
||||
{"id": 14, "title": "Midnight Cafe", "start_offset_sec": 173, "duration_sec": 200},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
`start_offset_sec`은 컴파일 시 acrossfade 적용을 고려한 누적 시작 시각 (=영상 자막 트리거 타이밍).
|
||||
|
||||
### 5-3. 변경 없음
|
||||
|
||||
`/feedback`, `/cancel`, `/publish`, `/setup`, `/youtube/*` 모두 그대로.
|
||||
|
||||
---
|
||||
|
||||
## 6. 백엔드 — NAS music-lab
|
||||
|
||||
### 6-1. `pipeline/orchestrator.py` 변경
|
||||
|
||||
`run_step`에 입력 audio 결정 로직 추가:
|
||||
|
||||
```python
|
||||
def _resolve_input(p: dict) -> dict:
|
||||
"""파이프라인 입력 = 단일 트랙 또는 컴파일 결과.
|
||||
|
||||
반환: {"audio_path": str, "duration_sec": int, "tracks": list[dict],
|
||||
"title": str, "genre": str, "moods": list, ...}
|
||||
"""
|
||||
if p.get("compile_job_id"):
|
||||
job = db.get_compile_job(p["compile_job_id"])
|
||||
if not job or job["status"] != "succeeded":
|
||||
raise ValueError(f"compile job {p['compile_job_id']} not ready")
|
||||
# 누적 offset 계산 (acrossfade 고려)
|
||||
tracks = []
|
||||
offset = 0.0
|
||||
crossfade = job["crossfade_sec"]
|
||||
for tid in job["track_ids"]:
|
||||
t = db.get_track_by_id(tid)
|
||||
tracks.append({
|
||||
"id": tid, "title": t["title"],
|
||||
"start_offset_sec": offset,
|
||||
"duration_sec": t["duration_sec"],
|
||||
})
|
||||
offset += t["duration_sec"] - crossfade # acrossfade overlap만큼 차감
|
||||
return {
|
||||
"audio_path": job["audio_path"], # /app/data/compiles/{id}.mp3
|
||||
"duration_sec": int(offset + crossfade), # 마지막 트랙은 풀 길이
|
||||
"tracks": tracks,
|
||||
"title": job["title"] or "Mix",
|
||||
"genre": "mix",
|
||||
"moods": [],
|
||||
}
|
||||
else:
|
||||
t = db.get_track_by_id(p["track_id"])
|
||||
return {
|
||||
"audio_path": t["file_path"],
|
||||
"duration_sec": t["duration_sec"],
|
||||
"tracks": [{"id": t["id"], "title": t["title"],
|
||||
"start_offset_sec": 0, "duration_sec": t["duration_sec"]}],
|
||||
"title": t["title"], "genre": t["genre"], "moods": t.get("moods", []),
|
||||
}
|
||||
```
|
||||
|
||||
각 step runner는 `_resolve_input(p)` 결과를 사용:
|
||||
- `_run_cover`: `genre`, `moods`, `title` 활용 (Mix면 `genre="mix"` → "mix" 키 prompt 또는 default)
|
||||
- `_run_video`: `audio_path`, `duration_sec`, `tracks` 모두 Windows로 전달
|
||||
- `_run_meta`: `tracks` 리스트를 메타 prompt에 포함
|
||||
- `_run_review`: `tracks` 리스트를 검토 prompt에 포함 (트랙 수, 다양한 장르 등)
|
||||
|
||||
### 6-2. `pipeline/cover.py` Pexels 폴백/대안
|
||||
|
||||
```python
|
||||
async def generate(*, pipeline_id: int, genre: str, prompt_template: str,
|
||||
mood: str = "", track_title: str = "", feedback: str = "",
|
||||
image_source: str = "ai") -> dict:
|
||||
"""image_source: 'ai' (DALL·E) | 'pexels' (스톡 검색)."""
|
||||
if image_source == "pexels":
|
||||
return await _generate_with_pexels(pipeline_id, genre, mood, track_title)
|
||||
# 기존 AI 흐름 그대로
|
||||
...
|
||||
# AI 실패 시 — 그라데이션 폴백 대신 Pexels 시도 (config 옵션)
|
||||
...
|
||||
```
|
||||
|
||||
신규 `_generate_with_pexels`:
|
||||
- Pexels API: `GET https://api.pexels.com/v1/search?query={keyword}&per_page=10`
|
||||
- 결과 1번째 큰 사진 다운로드 → `/app/data/videos/{id}/cover.jpg`
|
||||
- API key 미설정/실패 시 그라데이션 폴백
|
||||
|
||||
### 6-3. 신규 `pipeline/background.py` (video_loop 모드)
|
||||
|
||||
```python
|
||||
async def fetch_video_loop(pipeline_id: int, keyword: str) -> dict:
|
||||
"""Pexels Video API로 5–15초 루프 영상 받아옴.
|
||||
|
||||
/app/data/videos/{id}/loop.mp4 저장.
|
||||
"""
|
||||
# GET https://api.pexels.com/videos/search?query=...&per_page=5
|
||||
# SD/HD 720p 중에서 골라 다운로드
|
||||
...
|
||||
return {"path": "/app/data/videos/{id}/loop.mp4", "duration_sec": ...}
|
||||
```
|
||||
|
||||
오케스트레이터에서 `background_mode == "video_loop"` 분기 시 cover step 대신 또는 보조로 호출 (디자인 결정: cover step을 두 모드의 공통 입력 준비 단계로 통합 — 정적이면 cover.jpg, 영상이면 loop.mp4).
|
||||
|
||||
### 6-4. `pipeline/metadata.py` Mix 지원
|
||||
|
||||
`generate(*, track, template, trend_keywords, feedback="", tracks=None)` 시그니처 확장. `tracks` 있으면 Claude prompt에 다음 추가:
|
||||
|
||||
```
|
||||
이 영상은 {len(tracks)}개 트랙의 mix입니다. 트랙 리스트:
|
||||
1. [00:00] Lo-Fi Drive — lo-fi
|
||||
2. [03:00] Midnight Cafe — lo-fi
|
||||
...
|
||||
설명에는 트랙 리스트를 타임스탬프와 함께 포함하세요.
|
||||
```
|
||||
|
||||
응답 description은 자동으로 트랙리스트 포함됨. 이는 YouTube에서 챕터로 자동 인식.
|
||||
|
||||
### 6-5. `pipeline/video.py` (NAS측, 변경 작음)
|
||||
|
||||
기존 함수에 추가 파라미터 전달:
|
||||
|
||||
```python
|
||||
def generate(*, pipeline_id, audio_path, cover_path, genre, duration_sec,
|
||||
resolution="1920x1080", style="essential",
|
||||
background_mode="static", background_path=None,
|
||||
tracks=None) -> dict:
|
||||
payload = {
|
||||
"audio_path_nas": ..., "cover_path_nas": ...,
|
||||
"output_path_nas": ...,
|
||||
"resolution": resolution,
|
||||
"duration_sec": duration_sec,
|
||||
"style": style, # NEW: single | essential
|
||||
"background_mode": background_mode, # NEW: static | video_loop
|
||||
"background_path_nas": ..., # NEW: video_loop일 때 loop.mp4 경로
|
||||
"tracks": tracks, # NEW: Mix면 트랙 리스트 (자막용)
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
### 6-6. `db.py` 변경
|
||||
|
||||
신규 컬럼 추가 마이그레이션 + `get_compile_job(id)` (없으면 추가) + `get_track_by_id(id)` 활용.
|
||||
|
||||
---
|
||||
|
||||
## 7. 백엔드 — Windows music_ai
|
||||
|
||||
### 7-1. `/encode_video` 요청 확장
|
||||
|
||||
```json
|
||||
{
|
||||
"audio_path_nas": "...",
|
||||
"cover_path_nas": "...",
|
||||
"output_path_nas": "...",
|
||||
"resolution": "1920x1080",
|
||||
"duration_sec": 3600,
|
||||
"style": "essential", // NEW
|
||||
"background_mode": "static", // NEW
|
||||
"background_path_nas": "...", // NEW: video_loop면 loop.mp4
|
||||
"tracks": [ // NEW: 자막용
|
||||
{"start_offset_sec": 0, "title": "Lo-Fi Drive"},
|
||||
{"start_offset_sec": 173, "title": "Midnight Cafe"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 7-2. `video_encoder.py` 분기 로직
|
||||
|
||||
```python
|
||||
def encode_video(*, ..., style="essential", background_mode="static",
|
||||
background_path_nas=None, tracks=None):
|
||||
if style == "single":
|
||||
cmd = build_single_track_cmd(...)
|
||||
else: # essential
|
||||
if background_mode == "static":
|
||||
cmd = build_essential_static_cmd(cover, audio, out, w, h, tracks)
|
||||
else:
|
||||
bg = translate_path(background_path_nas)
|
||||
cmd = build_essential_video_loop_cmd(bg, audio, out, w, h, tracks)
|
||||
...
|
||||
```
|
||||
|
||||
### 7-3. Essential 정적 ffmpeg 명령
|
||||
|
||||
핵심 filter_complex 구조:
|
||||
|
||||
```
|
||||
[0:v]scale=1920:1080,format=yuv420p[bg]; # 정적 배경 사진
|
||||
[1:a]showfreqs=s=400x200:mode=bar:cmode=combined:colors=0xFFFFFF@0.9[bars]; # 중앙 막대
|
||||
[2:v]format=rgba[ring]; # 데코 ring PNG (사전 제작 1장)
|
||||
[bg][bars]overlay=(W-w)/2:(H-h)/2[mid]; # 막대 정중앙 배치
|
||||
[mid][ring]overlay=(W-w)/2:(H-h)/2[viz]; # ring 데코 같은 위치
|
||||
[viz]drawtext=...:enable='between(t,0,5)+between(t,173,178)+...'[final]
|
||||
```
|
||||
|
||||
- `showfreqs s=400x200 mode=bar` — 가로 막대 (방사형 근사 1차 버전)
|
||||
- `ring.png` — 사전 제작된 투명 PNG (`music_ai/assets/visualizer_ring.png`, 단순 흰색 원 + 외곽 점선)
|
||||
- `drawtext` — 트랙 리스트 순회하며 enable expression 동적 생성
|
||||
|
||||
향후(V2): `showcqt`나 `showspectrum` 시도 + 진짜 360° 방사형은 외부 도구(예: SuperCollider, butterchurn) 검토.
|
||||
|
||||
### 7-4. Essential 영상 루프 ffmpeg 명령
|
||||
|
||||
```
|
||||
[0:v]scale=1920:1080,setpts=PTS-STARTPTS[bg_loop];
|
||||
loop=loop=-1:size=N # 루프 영상 무한 반복
|
||||
[1:a]showfreqs=...[bars];
|
||||
[bg_loop][bars]overlay=center[mid];
|
||||
[mid][ring]overlay=center[viz];
|
||||
... drawtext 동일
|
||||
```
|
||||
|
||||
루프는 `-stream_loop -1 -i loop.mp4` 입력 옵션 + `-shortest` 출력으로 audio 길이만큼 반복.
|
||||
|
||||
### 7-5. 자막(곡명) drawtext
|
||||
|
||||
```python
|
||||
def build_drawtext_filter(tracks, total_duration):
|
||||
expressions = []
|
||||
for tr in tracks:
|
||||
start = tr["start_offset_sec"]
|
||||
end = start + 5 # 5초 표시
|
||||
# alpha fade in/out
|
||||
text = tr["title"].replace(":", r"\:").replace("'", r"\'")
|
||||
expressions.append(
|
||||
f"drawtext=fontfile='Arial Bold':text='{text}'"
|
||||
f":fontcolor=white:fontsize=36:x=(w-text_w)/2:y=h-100"
|
||||
f":alpha='if(between(t,{start},{end}),"
|
||||
f" if(lt(t-{start},1), t-{start}," # 0~1s fade in
|
||||
f" if(gt(t-{start},4), {end}-t, 1)), 0)'" # 4~5s fade out
|
||||
)
|
||||
return ",".join(expressions) # 체인으로 연결
|
||||
```
|
||||
|
||||
폰트는 Windows에 기본 설치된 Arial 또는 NanumGothic 사용. 한글 트랙명 지원 위해 NanumGothic 권장.
|
||||
|
||||
### 7-6. 신규 자산 파일
|
||||
|
||||
`music_ai/assets/visualizer_ring.png` — 1920×1080 캔버스 정중앙 400×400 영역에 그려진 흰색 원형 (외곽선 + 옅은 inner glow). 사전 제작 1장 — Pillow로 자동 생성도 가능 (서버 시작 시 없으면 생성).
|
||||
|
||||
---
|
||||
|
||||
## 8. 프론트엔드 변경
|
||||
|
||||
### 8-1. `CompileTab.jsx` — 영상 만들기 버튼
|
||||
|
||||
완료된 compile job 카드에 버튼 추가:
|
||||
|
||||
```jsx
|
||||
{job.status === 'succeeded' && (
|
||||
<button onClick={() => handleVideoFromCompile(job.id)}>
|
||||
🎬 영상 만들기
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
`handleVideoFromCompile`:
|
||||
```js
|
||||
async (compileJobId) => {
|
||||
const p = await createPipeline({ compile_job_id: compileJobId });
|
||||
await startPipeline(p.id);
|
||||
// 진행 탭으로 이동 (router push 또는 setTab + setOpenPipelineFor 패턴)
|
||||
};
|
||||
```
|
||||
|
||||
### 8-2. `PipelineStartModal.jsx` 확장
|
||||
|
||||
```jsx
|
||||
const [inputType, setInputType] = useState('track'); // 'track' | 'compile'
|
||||
const [compileJobs, setCompileJobs] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputType === 'compile') getCompileJobs().then(setCompileJobs);
|
||||
}, [inputType]);
|
||||
|
||||
return (
|
||||
<div className="modal-body">
|
||||
<h3>새 파이프라인 시작</h3>
|
||||
|
||||
<fieldset>
|
||||
<legend>입력</legend>
|
||||
<label><input type="radio" checked={inputType==='track'}
|
||||
onChange={() => setInputType('track')}/> 단일 트랙</label>
|
||||
<label><input type="radio" checked={inputType==='compile'}
|
||||
onChange={() => setInputType('compile')}/> Mix (컴파일 결과)</label>
|
||||
</fieldset>
|
||||
|
||||
{inputType === 'track' && (
|
||||
<select>{library.map(...)}</select>
|
||||
)}
|
||||
{inputType === 'compile' && (
|
||||
<select>{compileJobs.filter(j=>j.status==='succeeded').map(j =>
|
||||
<option key={j.id} value={j.id}>{j.title} ({j.tracks_count}곡, {fmtDuration(j.duration_sec)})</option>
|
||||
)}</select>
|
||||
)}
|
||||
|
||||
{/* 시각 모드 override */}
|
||||
<details>
|
||||
<summary>고급 옵션</summary>
|
||||
<select>visual_style: single | essential</select>
|
||||
<select>background_mode: static | video_loop</select>
|
||||
<input>background_keyword</input>
|
||||
</details>
|
||||
|
||||
{/* ... 기존 시작/취소 버튼 */}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### 8-3. `PipelineCard.jsx` — 미리보기 inline
|
||||
|
||||
```jsx
|
||||
return (
|
||||
<div className="pipeline-card" onClick={() => setShowDetail(true)}>
|
||||
<div className="pipeline-card__head">
|
||||
<h4>{pipeline.track_title || pipeline.compile_title || `Pipeline #${pipeline.id}`}</h4>
|
||||
<span className="pipeline-style-badge">{pipeline.visual_style}</span>
|
||||
...
|
||||
</div>
|
||||
|
||||
{/* 미니 미리보기 */}
|
||||
<div className="pipeline-previews">
|
||||
{pipeline.cover_url && <img src={pipeline.cover_url} alt="" className="pipeline-preview-mini" />}
|
||||
{pipeline.thumbnail_url && <img src={pipeline.thumbnail_url} alt="" className="pipeline-preview-mini" />}
|
||||
{pipeline.video_url && <span className="pipeline-video-icon">▶</span>}
|
||||
</div>
|
||||
|
||||
{/* 진행도 바 + 현재 상태 (기존) */}
|
||||
...
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### 8-4. `PipelineDetailModal.jsx` (신규)
|
||||
|
||||
```jsx
|
||||
export default function PipelineDetailModal({ pipeline, onClose }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-body modal-body--lg" onClick={e=>e.stopPropagation()}>
|
||||
<header>
|
||||
<h3>{pipeline.compile_title || pipeline.track_title}</h3>
|
||||
<span className="badge">{pipeline.visual_style}</span>
|
||||
<button onClick={onClose}>×</button>
|
||||
</header>
|
||||
|
||||
{/* 큰 미리보기 그리드 */}
|
||||
<div className="pdm-grid">
|
||||
{pipeline.cover_url && (
|
||||
<figure>
|
||||
<img src={pipeline.cover_url} alt="cover" />
|
||||
<figcaption>커버 (배경)</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
{pipeline.thumbnail_url && (
|
||||
<figure>
|
||||
<img src={pipeline.thumbnail_url} alt="thumbnail" />
|
||||
<figcaption>썸네일</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 영상 플레이어 */}
|
||||
{pipeline.video_url && (
|
||||
<div className="pdm-video">
|
||||
<video src={pipeline.video_url} controls width="100%" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메타데이터 */}
|
||||
{pipeline.metadata && (
|
||||
<section className="pdm-meta">
|
||||
<h4>메타데이터</h4>
|
||||
<p><strong>제목:</strong> {pipeline.metadata.title}</p>
|
||||
<details>
|
||||
<summary>설명</summary>
|
||||
<pre>{pipeline.metadata.description}</pre>
|
||||
</details>
|
||||
<p><strong>태그:</strong> {pipeline.metadata.tags?.join(', ')}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* AI 검토 */}
|
||||
{pipeline.review && (
|
||||
<section className="pdm-review">
|
||||
<h4>AI 검토 — <span className="badge">{pipeline.review.verdict}</span> ({pipeline.review.weighted_total}/100)</h4>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td>메타데이터 품질</td><td>{pipeline.review.metadata_quality.score}</td></tr>
|
||||
<tr><td>콘텐츠 정책</td><td>{pipeline.review.policy_compliance.score}</td></tr>
|
||||
<tr><td>시청 경험</td><td>{pipeline.review.viewer_experience.score}</td></tr>
|
||||
<tr><td>트렌드 정렬</td><td>{pipeline.review.trend_alignment.score}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><em>{pipeline.review.summary}</em></p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 트랙 리스트 (Mix일 때) */}
|
||||
{pipeline.tracks && pipeline.tracks.length > 1 && (
|
||||
<section className="pdm-tracks">
|
||||
<h4>트랙 리스트 ({pipeline.tracks.length})</h4>
|
||||
<ol>
|
||||
{pipeline.tracks.map(t => (
|
||||
<li key={t.id}>
|
||||
[{fmtTimestamp(t.start_offset_sec)}] {t.title} ({fmtDuration(t.duration_sec)})
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 피드백 히스토리 */}
|
||||
{pipeline.feedback && pipeline.feedback.length > 0 && (
|
||||
<section className="pdm-feedback">
|
||||
<h4>피드백 ({pipeline.feedback.length})</h4>
|
||||
<ul>
|
||||
{pipeline.feedback.map(f => (
|
||||
<li key={f.id}>
|
||||
<code>[{f.step}]</code> {f.feedback_text}
|
||||
<small>{f.received_at}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* YouTube 링크 */}
|
||||
{pipeline.youtube_video_id && (
|
||||
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
|
||||
target="_blank" rel="noreferrer" className="pdm-youtube">
|
||||
🎬 YouTube에서 보기
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 8-5. `SetupTab.jsx` 확장
|
||||
|
||||
영상 비주얼 기본값 카드 확장:
|
||||
- **default_visual_style** 드롭다운: `single` / `essential`
|
||||
- **default_background_mode** 드롭다운: `static` / `video_loop`
|
||||
- **default_background_keyword** 텍스트 입력 (예: "lofi cafe")
|
||||
- **background_image_source** 드롭다운: `ai` / `pexels`
|
||||
- **subtitle_track_titles** 체크박스: Mix에서 곡명 자막 표시
|
||||
|
||||
---
|
||||
|
||||
## 9. 환경변수 (NAS측)
|
||||
|
||||
신규 — 이미 `.env`에 있을 가능성 높음:
|
||||
```env
|
||||
PEXELS_API_KEY=xxx # 이미 있음 (현재 미사용)
|
||||
```
|
||||
|
||||
신규 (Windows측 — music_ai/.env):
|
||||
```env
|
||||
# 한글 자막용 폰트 경로 (선택)
|
||||
SUBTITLE_FONT=C:\Windows\Fonts\malgun.ttf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 에러 처리
|
||||
|
||||
| 시나리오 | 결과 |
|
||||
|---------|------|
|
||||
| compile_job 미완료 (status != succeeded) | POST /pipeline 시 400 |
|
||||
| compile_job 삭제됨 | get_pipeline에서 `compile_title=null`, 진행 탭에 "삭제됨" 배지 |
|
||||
| Pexels API 실패 (image) | AI 폴백 |
|
||||
| Pexels API 실패 (video) | 단색 폴백 + 텔레그램에 "Pexels 실패" 명시 |
|
||||
| drawtext 자막 한글 폰트 누락 | 자막 없이 인코딩 + 경고 로그 |
|
||||
| 1시간 NVENC timeout | 영상 단계 timeout 600s → 그래도 부족하면 failed (보통 NVENC면 5분 내) |
|
||||
|
||||
---
|
||||
|
||||
## 11. 테스트 전략
|
||||
|
||||
### 11-1. 단위 테스트 (NAS music-lab)
|
||||
|
||||
| 대상 | 테스트 |
|
||||
|------|--------|
|
||||
| `orchestrator._resolve_input` | track_id 분기 / compile_job_id 분기 / 둘 다 / 둘 다 없음 / compile not ready |
|
||||
| `cover.generate` `image_source='pexels'` | Pexels API mock + 다운로드 + 파일 저장 |
|
||||
| `background.fetch_video_loop` | Pexels Video API mock + mp4 다운로드 |
|
||||
| `metadata.generate` `tracks=[...]` | 트랙 리스트가 prompt에 포함되는지, 응답 description에 chapter 포맷 |
|
||||
| API `POST /pipeline { compile_job_id }` | 정상 / not ready 400 / 둘 다 400 / 단일은 기존 작동 |
|
||||
| DB 마이그레이션 | 새 컬럼 default 값 |
|
||||
|
||||
### 11-2. 단위 테스트 (Windows music_ai)
|
||||
|
||||
| 대상 | 테스트 |
|
||||
|------|--------|
|
||||
| `build_essential_static_cmd` | filter_complex 문자열 검증 (showfreqs, overlay 위치 등) |
|
||||
| `build_drawtext_filter` | 트랙 N개 → enable expression N개 생성, alpha fade 검증 |
|
||||
| `encode_video` `style='essential'` | 새 분기 호출됨 |
|
||||
| `encode_video` `style='single'` | 기존 단일 트랙 명령 그대로 |
|
||||
| 자산 ring.png 자동 생성 | 서버 시작 시 없으면 PIL로 생성 |
|
||||
|
||||
### 11-3. 통합 테스트
|
||||
|
||||
`test_essential_pipeline_flow.py`:
|
||||
- compile job 생성 → 파이프라인 시작 (compile_job_id) → 모든 단계 mock → published → tracks 리스트가 metadata description에 포함됐는지
|
||||
|
||||
### 11-4. 수동 E2E
|
||||
|
||||
- [ ] 컴파일 탭에서 3-5분 mix 컴파일
|
||||
- [ ] "🎬 영상 만들기" 클릭 → 진행 탭 카드 생성, visual_style=essential
|
||||
- [ ] cover 단계 → 텔레그램 알림 + 카드에 cover 미니 썸네일 표시
|
||||
- [ ] 카드 클릭 → 상세 모달 → cover 큰 이미지, 메타·검토 영역 표시 (해당 단계 진행 시)
|
||||
- [ ] 모든 단계 승인 → 발행 → YouTube 비공개 영상에 essential 시각 + 챕터 자동 인식 확인
|
||||
- [ ] 1시간 mix로 동일 흐름 — Windows NVENC 인코딩 시간 5분 미만 확인
|
||||
- [ ] background_mode=video_loop로 시도 — Pexels 영상 다운로드 + 루프 인코딩
|
||||
|
||||
---
|
||||
|
||||
## 12. 마이그레이션 + 배포
|
||||
|
||||
### 12-1. DB 마이그레이션
|
||||
|
||||
`init_db()` 신규 컬럼 `ALTER TABLE` (SQLite는 idempotent: 컬럼 존재 확인 후 추가):
|
||||
```python
|
||||
def _add_column_if_missing(cursor, table, column, ddl):
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
cols = [r[1] for r in cursor.fetchall()]
|
||||
if column not in cols:
|
||||
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
|
||||
```
|
||||
|
||||
### 12-2. 자산 파일
|
||||
|
||||
`music_ai/assets/visualizer_ring.png`은 git에 커밋 (small, ~30KB). Windows 측이므로 사용자가 수동 배포 (이미 music_ai는 로컬 전용).
|
||||
|
||||
또는 **서버 시작 시 자동 생성** (PIL로 단순 ring 그리기) — 권장. assets 디렉토리도 자동 생성.
|
||||
|
||||
### 12-3. 환경변수
|
||||
|
||||
NAS `.env` 변경 없음 (PEXELS_API_KEY 이미 있음).
|
||||
Windows `.env`에 `SUBTITLE_FONT` 추가 (선택).
|
||||
|
||||
---
|
||||
|
||||
## 13. 산출물
|
||||
|
||||
| 영역 | 파일 |
|
||||
|------|------|
|
||||
| Spec/Plan | 본 문서 + plan |
|
||||
| NAS music-lab | `db.py` (마이그레이션), `pipeline/orchestrator.py` (resolve_input), `pipeline/cover.py` (Pexels 분기), `pipeline/background.py` (신규), `pipeline/metadata.py` (tracks 옵션), `pipeline/video.py` (style/background 파라미터), `app/main.py` (POST /pipeline body 확장) |
|
||||
| Windows music_ai | `video_encoder.py` (style 분기, drawtext, ring), `server.py` (요청 schema 확장), `assets/visualizer_ring.png` (자동 생성), Pillow 이미 있음 |
|
||||
| Frontend | `CompileTab.jsx` (영상 만들기 버튼), `PipelineStartModal.jsx` (라디오), `PipelineCard.jsx` (미리보기 inline), `PipelineDetailModal.jsx` (신규), `SetupTab.jsx` (visual_defaults 확장), `api.js` 헬퍼 추가, `MusicStudio.css` 스타일 |
|
||||
| 테스트 | NAS 단위 6+ / Windows 단위 5+ / 통합 1 / 수동 E2E |
|
||||
|
||||
---
|
||||
|
||||
## 14. 후속 (P3)
|
||||
|
||||
- 사용자 직접 사진/영상 업로드
|
||||
- 텔레그램에 cover/thumbnail 사진 첨부
|
||||
- 360° 진짜 방사형 visualizer (외부 도구 또는 GPU shader)
|
||||
- AI 검토 가중치 mix vs 단일 자동 분리
|
||||
- Pexels 검색 미리보기 UI (구성 탭에서 "이 키워드로 검색해보기" 버튼)
|
||||
|
||||
---
|
||||
486
docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md
Normal file
486
docs/superpowers/specs/2026-05-09-gpu-video-offload-design.md
Normal file
@@ -0,0 +1,486 @@
|
||||
# GPU 영상 인코딩 오프로드 — 설계
|
||||
|
||||
> 작성일: 2026-05-09
|
||||
> 관련: `2026-05-07-music-youtube-pipeline-design.md` (Task 4 대체)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경
|
||||
|
||||
NAS Synology Celeron J4025(2 cores @ 2.0GHz, GPU 없음)에서 1920×1080 visualizer 영상 인코딩이 너무 느림. 176초 트랙 인코딩에 5분 초과 → ffmpeg `subprocess.TimeoutExpired`. `-preset ultrafast`로 가속해도 한계 있고 화질 저하.
|
||||
|
||||
대안: 사용자 Windows PC(RTX 5070 Ti, 16GB VRAM)에서 NVIDIA NVENC 하드웨어 인코딩으로 처리. 같은 영상이 **10–20초**에 완료(20×+ 빠름).
|
||||
|
||||
이미 `music_ai` 서버(Windows, port 8765)가 MusicGen용으로 동작 중이므로 **같은 서버에 영상 인코딩 endpoint를 추가**하는 것이 가장 자연스럽다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 비목표
|
||||
|
||||
- 다중 GPU/멀티 머신 — 단일 Windows PC만 지원
|
||||
- NAS 로컬 ffmpeg 폴백 — 사용자 결정으로 제외 (Windows 서버 다운 시 명확한 실패 선호)
|
||||
- 영상 길이 제한 — 일반 트랙 길이(1–10분) 가정
|
||||
- 인증 — LAN 전용, 무인증
|
||||
|
||||
---
|
||||
|
||||
## 3. 아키텍처
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ NAS (Synology) │
|
||||
│ │
|
||||
│ music-lab container │
|
||||
│ pipeline/video.py │
|
||||
│ ↓ HTTP POST {paths, resolution} │
|
||||
│ ↓ 192.168.45.59:8765/encode_video │
|
||||
│ │
|
||||
│ /volume1/docker/webpage/data/ │
|
||||
│ videos/{id}/cover.jpg ← input │
|
||||
│ videos/{id}/video.mp4 ← output (Windows가 직접 씀) │
|
||||
│ {audio}.mp3 ← input │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
↓ HTTP ↑ SMB read/write
|
||||
↓ ↑ (Z:\ 마운트)
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Windows PC (192.168.45.59) │
|
||||
│ │
|
||||
│ music_ai server.py (port 8765) │
|
||||
│ • POST /generate (기존, MusicGen) │
|
||||
│ • POST /encode_video (신규) │
|
||||
│ ↓ 경로 변환: /volume1/... → Z:\... │
|
||||
│ ↓ ffmpeg.exe -hwaccel cuda -c:v h264_nvenc ... │
|
||||
│ ↓ 입력/출력 모두 Z:\ 직접 (SMB) │
|
||||
│ ↓ 응답: {ok, duration_ms, output_path} │
|
||||
│ │
|
||||
│ Z:\docker\webpage\data\ (NAS SMB mount, 기존) │
|
||||
│ videos\{id}\cover.jpg │
|
||||
│ videos\{id}\video.mp4 │
|
||||
│ {audio}.mp3 │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**핵심 원칙:** 파일은 SMB로 직접 읽고 쓰기 — HTTP는 메타데이터(경로 + 옵션)만 전달.
|
||||
|
||||
---
|
||||
|
||||
## 4. Windows `music_ai` 서버 — `/encode_video` endpoint
|
||||
|
||||
### 4-1. Request
|
||||
|
||||
```http
|
||||
POST /encode_video HTTP/1.1
|
||||
Host: 192.168.45.59:8765
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `cover_path_nas` | string | ✓ | 배경 이미지 NAS 절대경로 |
|
||||
| `audio_path_nas` | string | ✓ | 오디오 파일 NAS 절대경로 |
|
||||
| `output_path_nas` | string | ✓ | 출력 mp4 NAS 절대경로 |
|
||||
| `resolution` | string | ✓ | `WIDTHxHEIGHT` (예: `1920x1080`) |
|
||||
| `duration_sec` | int | | 트랙 길이 — 진행 추적용 (옵션) |
|
||||
| `style` | string | | 현재 `visualizer`만 (확장용) |
|
||||
|
||||
### 4-2. Response
|
||||
|
||||
**성공 (200):**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"duration_ms": 12340,
|
||||
"output_path_nas": "/volume1/docker/webpage/data/videos/3/video.mp4",
|
||||
"output_bytes": 28470000,
|
||||
"encoder": "h264_nvenc",
|
||||
"preset": "p4"
|
||||
}
|
||||
```
|
||||
|
||||
**실패 (4xx/5xx):**
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "ffmpeg returncode=1: ...",
|
||||
"stage": "ffmpeg" // path_translate | input_validation | ffmpeg | output_check
|
||||
}
|
||||
```
|
||||
|
||||
### 4-3. 경로 변환
|
||||
|
||||
Windows 서버는 `nas_path → windows_path` 변환을 환경변수 기반으로 수행:
|
||||
|
||||
```python
|
||||
# .env (Windows music_ai)
|
||||
NAS_VOLUME_PREFIX=/volume1/
|
||||
WINDOWS_DRIVE_ROOT=Z:\
|
||||
```
|
||||
|
||||
변환 로직:
|
||||
```python
|
||||
def translate_path(nas_path: str) -> str:
|
||||
# /volume1/docker/webpage/data/videos/3/cover.jpg
|
||||
# → Z:\docker\webpage\data\videos\3\cover.jpg
|
||||
if not nas_path.startswith(NAS_VOLUME_PREFIX):
|
||||
raise ValueError(f"NAS prefix 불일치: {nas_path}")
|
||||
rel = nas_path[len(NAS_VOLUME_PREFIX):] # "docker/webpage/..."
|
||||
return WINDOWS_DRIVE_ROOT + rel.replace("/", "\\")
|
||||
```
|
||||
|
||||
### 4-4. 입력 검증
|
||||
|
||||
ffmpeg 호출 전:
|
||||
- `cover_path` 변환된 Windows 경로의 파일 존재 확인 → 없으면 400 stage=input_validation
|
||||
- `audio_path` 동일
|
||||
- `output_path`의 부모 디렉토리 존재 확인 — 없으면 자동 생성
|
||||
- `resolution` 정규식 `^\d{3,4}x\d{3,4}$` 검증 → 실패 시 400
|
||||
|
||||
### 4-5. ffmpeg 명령 (NVENC)
|
||||
|
||||
```python
|
||||
def build_visualizer_cmd(cover_win, audio_win, out_win, w, h):
|
||||
return [
|
||||
"ffmpeg", "-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", # quality preset (p1=fastest, p7=slowest/best)
|
||||
"-rc", "vbr",
|
||||
"-cq", "23", # quality (lower=better, 18-25 sane range)
|
||||
"-b:v", "0", # let CQ control bitrate
|
||||
"-pix_fmt", "yuv420p", # YouTube 호환
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-shortest", out_win,
|
||||
]
|
||||
```
|
||||
|
||||
**주요 플래그 설명:**
|
||||
- `-hwaccel cuda` — CUDA 사용
|
||||
- `-c:v h264_nvenc` — NVIDIA NVENC H.264 인코더
|
||||
- `-preset p4` — 품질·속도 균형 (5070 Ti 기준 1080p 영상 ~10–20s)
|
||||
- `-rc vbr -cq 23 -b:v 0` — VBR + 일정 품질 (CQ 23 = ~CRF 23)
|
||||
- `format=yuv420p` 명시 — NVENC가 가끔 yuv444 출력하는데 YouTube 호환 X
|
||||
|
||||
### 4-6. 타임아웃 + 출력 검증
|
||||
|
||||
- ffmpeg subprocess timeout: **180초** (NAS 측 HTTP timeout 200s 미만)
|
||||
- 종료 후 출력 파일 존재 + 크기 > 1MB 검증 → 미달 시 stage=output_check 실패
|
||||
- 종료 코드 0이지만 파일 비어있는 케이스 catch
|
||||
|
||||
### 4-7. 동시 처리
|
||||
|
||||
별도 큐 없음. 동시 호출 시 ffmpeg 프로세스 병렬 실행 — RTX 5070 Ti는 NVENC 세션 5개까지 지원.
|
||||
|
||||
단일 사용자 시나리오에서 동시 인코딩은 거의 발생 안 함. 발생해도 GPU 리소스 충분.
|
||||
|
||||
### 4-8. 헬스 체크 확장
|
||||
|
||||
기존 `GET /health`에 인코더 가용성 정보 추가:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"gpu": "NVIDIA GeForce RTX 5070 Ti",
|
||||
"musicgen_loaded": true,
|
||||
"ffmpeg_path": "C:/ffmpeg/bin/ffmpeg.exe",
|
||||
"ffmpeg_nvenc": true
|
||||
}
|
||||
```
|
||||
|
||||
`ffmpeg_nvenc` 검증: 서버 시작 시 `ffmpeg -encoders | grep h264_nvenc` 한 번 실행 + 캐시.
|
||||
|
||||
---
|
||||
|
||||
## 5. NAS music-lab — `pipeline/video.py` 리팩토링
|
||||
|
||||
### 5-1. 환경변수 (필수)
|
||||
|
||||
```env
|
||||
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
|
||||
```
|
||||
|
||||
미설정 시: `pipeline/video.py`가 기동 시 명확한 에러로 실패 (ImportError 또는 RuntimeError).
|
||||
|
||||
### 5-2. `video.generate(...)` — 새 구현
|
||||
|
||||
```python
|
||||
"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출."""
|
||||
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 + 마진
|
||||
|
||||
|
||||
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 서버 호출. 다운/실패 시 즉시 예외."""
|
||||
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 인코더 호출: %s → %s", audio_path, out_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) as e:
|
||||
raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
detail = resp.json()
|
||||
except Exception:
|
||||
detail = {"error": resp.text[:300]}
|
||||
raise VideoGenerationError(
|
||||
f"Windows 인코더 오류 ({resp.status_code}): "
|
||||
f"{detail.get('stage','?')} — {detail.get('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
|
||||
"""
|
||||
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")
|
||||
if container_path.startswith("/app/data/videos/"):
|
||||
return container_path.replace("/app/data/videos/", nas_videos_root + "/", 1)
|
||||
if container_path.startswith("/app/data/"):
|
||||
# 음악 파일 마운트가 /app/data 직접이라 서브디렉토리 없음 → music root에 직접
|
||||
rel = container_path[len("/app/data/"):]
|
||||
return nas_music_root + "/" + rel
|
||||
return container_path # fallback (shouldn't happen)
|
||||
```
|
||||
|
||||
### 5-3. 제거 항목
|
||||
|
||||
- `subprocess.run(...)` ffmpeg 호출 — 완전 제거
|
||||
- `VIDEO_TIMEOUT_S = 600` — 사용 안 함 (`ENCODER_TIMEOUT_S`로 대체)
|
||||
- `_build_visualizer_cmd` — 제거 (Windows 서버로 이전)
|
||||
- `subprocess.TimeoutExpired` 예외 처리 — 제거
|
||||
|
||||
### 5-4. 환경변수 (NAS music-lab)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml music-lab service environment
|
||||
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}
|
||||
```
|
||||
|
||||
NAS `.env` 추가:
|
||||
```env
|
||||
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 에러 응답 매트릭스
|
||||
|
||||
| 상황 | NAS 측 결과 | 사용자 경험 |
|
||||
|------|------------|-------------|
|
||||
| Windows PC 꺼짐 | `VideoGenerationError("연결 실패")` | 진행 카드 `failed`, 텔레그램에 명확한 에러 |
|
||||
| Windows ffmpeg 실패 | `VideoGenerationError("Windows 인코더 오류 500: ffmpeg — ...")` | 동일 |
|
||||
| 입력 파일 NAS에 없음 | Windows가 400 응답 | "input_validation: cover not found" 메시지 |
|
||||
| 출력 파일이 비어있음 | Windows가 500 응답 | "output_check: file empty" |
|
||||
| 타임아웃 (180s+) | Windows가 504 응답 또는 connection close | "타임아웃 — GPU 부하 또는 입력 손상" |
|
||||
| WINDOWS_VIDEO_ENCODER_URL 미설정 | 즉시 `VideoGenerationError` | 환경 미설정 안내 |
|
||||
|
||||
모두 pipeline state `failed`로 전이. 재생성 5회 한도 적용.
|
||||
|
||||
---
|
||||
|
||||
## 7. 헬스 모니터링
|
||||
|
||||
NAS music-lab 시작 시 1회 `GET {ENCODER_URL}/health` 호출 → 결과를 로그에 출력:
|
||||
- 성공 + `ffmpeg_nvenc=true` → 인코더 사용 가능
|
||||
- 실패 → 경고 로그 (구동은 계속, 호출 시점에 명확한 에러)
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트 전략
|
||||
|
||||
### 8-1. NAS music-lab 단위 테스트
|
||||
|
||||
`music-lab/tests/test_video_thumb.py` — 기존 ffmpeg 테스트를 HTTP mock 기반으로 교체:
|
||||
|
||||
```python
|
||||
@respx.mock
|
||||
def test_generate_video_calls_remote_encoder(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.post("http://192.168.45.59:8765/encode_video").mock(
|
||||
return_value=Response(200, json={
|
||||
"ok": True, "duration_ms": 12000,
|
||||
"output_path_nas": "/volume1/...",
|
||||
"encoder": "h264_nvenc", "preset": "p4"
|
||||
})
|
||||
)
|
||||
out = video.generate(...)
|
||||
assert out["url"].endswith("/video.mp4")
|
||||
assert out["encode_duration_ms"] == 12000
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_generate_video_raises_on_connection_error(monkeypatch):
|
||||
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
|
||||
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(...)
|
||||
assert "연결 실패" in str(exc.value)
|
||||
|
||||
|
||||
def test_generate_video_no_url_configured(monkeypatch):
|
||||
monkeypatch.setattr(video, "ENCODER_URL", "")
|
||||
with pytest.raises(video.VideoGenerationError) as exc:
|
||||
video.generate(...)
|
||||
assert "WINDOWS_VIDEO_ENCODER_URL" in str(exc.value)
|
||||
```
|
||||
|
||||
기존 `test_generate_video_calls_ffmpeg` / `test_generate_video_failure_marks_failed` 제거.
|
||||
|
||||
### 8-2. Windows `music_ai` 단위 테스트
|
||||
|
||||
`music_ai/tests/test_video_encoder.py` (신규):
|
||||
|
||||
```python
|
||||
@patch("subprocess.run")
|
||||
def test_translate_path():
|
||||
assert video_encoder.translate_path("/volume1/docker/webpage/data/x.jpg") == r"Z:\docker\webpage\data\x.jpg"
|
||||
|
||||
def test_translate_path_rejects_bad_prefix():
|
||||
with pytest.raises(ValueError):
|
||||
video_encoder.translate_path("/something/else/x.jpg")
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_endpoint_success(mock_run, client, tmp_path):
|
||||
# mock paths exist + ffmpeg succeeds
|
||||
...
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_endpoint_input_missing(mock_run, client):
|
||||
# 입력 파일 안 보이면 400
|
||||
...
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_encode_endpoint_ffmpeg_fails(mock_run, client, tmp_path):
|
||||
# ffmpeg returncode=1 → 500 stage=ffmpeg
|
||||
...
|
||||
```
|
||||
|
||||
### 8-3. 통합 테스트
|
||||
|
||||
기존 `test_pipeline_flow.py`는 `cover.generate`를 mock하므로 영향 없음. video도 같이 mock — 변경 없음.
|
||||
|
||||
### 8-4. 수동 E2E
|
||||
|
||||
- [ ] Windows PC에서 `music_ai` 서버 시작 → `curl http://192.168.45.59:8765/health` → `ffmpeg_nvenc: true` 확인
|
||||
- [ ] NAS에서 `curl -X POST http://192.168.45.59:8765/encode_video -d '{...}'` 직접 호출 → 200 응답 + Z:\에 video.mp4 생성 확인
|
||||
- [ ] 진행 탭에서 새 파이프라인 시작 → 영상 단계가 10–20초 안에 완료 → 텔레그램 알림 도착
|
||||
- [ ] Windows PC 꺼두고 새 파이프라인 시작 → 영상 단계 즉시 실패 → 진행 카드 failed + 명확한 에러 메시지
|
||||
|
||||
---
|
||||
|
||||
## 9. Windows PC 사전 준비
|
||||
|
||||
사용자가 Windows PC에서 1회 수행할 작업:
|
||||
|
||||
1. **ffmpeg + NVENC 빌드 설치**
|
||||
- https://www.gyan.dev/ffmpeg/builds/ → "release full" 다운로드
|
||||
- 압축 해제 → `C:\ffmpeg\bin\ffmpeg.exe`
|
||||
- PATH 환경변수에 `C:\ffmpeg\bin` 추가
|
||||
- 검증: `ffmpeg -version` 동작, `ffmpeg -encoders | findstr h264_nvenc` 결과 출력
|
||||
|
||||
2. **NVIDIA 드라이버** — 이미 MusicGen용으로 설치돼 있음
|
||||
|
||||
3. **SMB 마운트 확인** — `Z:\docker\webpage\` 접근 가능해야 함
|
||||
|
||||
4. **방화벽** — 포트 8765 LAN 인바운드 허용 (이미 MusicGen용으로 설정돼 있음)
|
||||
|
||||
5. **`music_ai/.env`에 추가**:
|
||||
```env
|
||||
NAS_VOLUME_PREFIX=/volume1/
|
||||
WINDOWS_DRIVE_ROOT=Z:\
|
||||
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
|
||||
```
|
||||
|
||||
6. **`music_ai/start.bat` 재시작** — 새 endpoint 활성화
|
||||
|
||||
---
|
||||
|
||||
## 10. 산출물
|
||||
|
||||
| 영역 | 파일 |
|
||||
|------|------|
|
||||
| Windows | `music_ai/video_encoder.py` (신규) |
|
||||
| Windows | `music_ai/server.py` (수정 — `/encode_video` endpoint 등록, `/health` 확장) |
|
||||
| Windows | `music_ai/.env.example` (수정 — 새 변수 문서화) |
|
||||
| Windows | `music_ai/tests/test_video_encoder.py` (신규) |
|
||||
| NAS | `music-lab/app/pipeline/video.py` (재작성) |
|
||||
| NAS | `music-lab/tests/test_video_thumb.py` (수정 — HTTP mock 기반) |
|
||||
| Infra | `web-backend/docker-compose.yml` (env 3개 추가) |
|
||||
| Infra | NAS `.env` (사용자 수동, 1개 추가) |
|
||||
|
||||
---
|
||||
|
||||
## 11. 후속
|
||||
|
||||
- (P3) 영상 인코딩 진행률 실시간 보고 — Windows에서 ffmpeg progress 파싱 후 진행 탭 카드에 표시 (현재는 단순 "running")
|
||||
- (P3) Windows 서버 다중 큐 — 동시 요청 시 GPU 부하 추적 + 큐잉
|
||||
- (P4) 인코딩 옵션을 youtube_setup `visual_defaults`로 추가 — preset(p1~p7), CQ, 해상도 옵션 노출
|
||||
- (P4) Shorts 전용 1080×1920 인코딩 프로파일
|
||||
|
||||
---
|
||||
## 11. 후속
|
||||
|
||||
- (P3) 영상 인코딩 진행률 실시간 보고 — Windows에서 ffmpeg progress 파싱 후 진행 탭 카드에 표시 (현재는 단순 "running")
|
||||
- (P3) Windows 서버 다중 큐 — 동시 요청 시 GPU 부하 추적 + 큐잉
|
||||
- (P4) 인코딩 옵션을 youtube_setup `visual_defaults`로 추가 — preset(p1~p7), CQ, 해상도 옵션 노출
|
||||
- (P4) Shorts 전용 1080×1920 인코딩 프로파일
|
||||
|
||||
---
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user