10 Commits

Author SHA1 Message Date
83113ab50c docs(check-point): mark #10 already-applied, #11 denied, #12 deferred
#10 NAS LLM 호출 → Windows AI 통일 — 확인 결과 이미 적용. NAS .env가
LLM_PROVIDER=claude + OLLAMA_URL=192.168.45.59:11435. NAS Celeron에서
LLM 추론 안 함. 코드 변경 불필요.

#11 컨테이너 리소스 제한 (cpus 0.5 등) — 박재오 진행 금지. J4025 2C
환경에서 오히려 throughput 손해라는 판단.

#12 NAS 하드웨어 업그레이드 — 박재오 보류 결정.

또한 web-ai V1(:8000)+V2(:8001)+launcher 총 4개 process 종료. NAS API
polling 부담 즉각 감소.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 11:00:04 +09:00
20514193e8 perf(infra): NAS CPU 중기 2건 + 1건 보류 (CHECK_POINT 🟡)
#6 insta-lab Chromium Browser Pool — Playwright/Chromium 인스턴스를
모듈 레벨에서 보관하고 매 슬레이트마다 reuse. 카드 10장 렌더의
launch 비용 (~3초/회)이 사라짐. startup/shutdown lifecycle hook 추가.
crashed/disconnected 시 lazy 재초기화.

#8 realestate-lab 수집 병렬화 — collect_all과 delete_old_completed가
서로 다른 데이터 영역이라 ThreadPoolExecutor(2)로 병렬. asyncio.gather
대신 thread executor를 쓴 이유는 BackgroundScheduler+동기 함수 환경
에서 자연스럽고 추가 의존성 없기 때문. 매칭은 일관성 유지로 순차.

#7 stock async — 보류. 재진단 결과 stock은 BackgroundScheduler 사용
중이라 main loop 블로킹 없음. fetch 4회는 network I/O wait가
대부분이라 to_thread도 의미 없음. 진짜 효과를 보려면 AsyncIOScheduler
전환 + aiohttp 병렬이라 큰 리팩토링. 박재오 판단 대기.

CHECK_POINT.md 갱신.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:42:43 +09:00
7a470aad44 perf(infra): NAS CPU 폭주 5건 일괄 fix (CHECK_POINT 🔴 즉시)
J4025 Celeron 2C/2.0GHz에서 oversaturation을 일으키던 5개 패턴 해소.

1) 09:00 cron 스태거링 — agent-office insta_trends 09:00 / lotto 09:05 /
   youtube 09:10, realestate-lab collect 09:15. 동시 실행 4개가 직렬
   분산되어 1분 단위로 분산됨.
2) lotto Monte Carlo 08:05 → 08:30 — stock 08:00 cron과 25분 분리.
3) insta-lab card_renderer.render_slate를 asyncio.Semaphore(1)로 감쌈.
   동시 슬레이트 렌더 요청이 와도 Chromium 인스턴스 1개만 직렬 launch.
4) docker-compose healthcheck interval 30s → 60s (9 백엔드 + frontend
   총 10개). 30초마다 동시 healthcheck로 인한 CPU 잡음 절반으로.
5) 9개 백엔드 Dockerfile CMD에 --workers 1 명시. 기본값 의존 제거.

CHECK_POINT.md 갱신 — 즉시 5건 체크 + 변경 이력 한 줄.
적용 효과 검증: NAS 재기동 후 `docker stats` 비교.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:31:02 +09:00
de8adaeadd refactor(agent-office): drop the random idle→break→idle cycle
The pixel-office game UI is gone, so simulating coffee-break /
nap / walk states no longer serves any purpose. Remove:
- scheduler's _check_idle_breaks job (no more 60s idle scan)
- BaseAgent.check_idle_break() and _break_until field
- 'break' from VALID_STATES and from transition() branches
- IDLE_BREAK_THRESHOLD / BREAK_DURATION_MIN / BREAK_DURATION_MAX
  config knobs
- 'idle/break' guard in each agent's on_schedule (now just 'idle')

Agents now sit in 'idle' between scheduled jobs and explicit
commands. Display reads 'Idle' instead of churning between idle
and break.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:44:50 +09:00
5cde24115b feat(insta-lab): minimal 테마 card.html.j2 추가 (host repo 영속화)
NAS docker exec로 design_importer minimal 실행한 결과를 컨테이너에서 docker cp로
추출 → host repo에 영속화. 이전엔 컨테이너 ephemeral state라 다음 webhook rebuild에
소실되면서 렌더러가 default 폴백 → 사용자가 본 카드는 minimal 무관.

검증:
- 25,158 bytes, UTF-8 no BOM, <!DOCTYPE 시작
- Jinja parse OK
- background-image 10건, _order.json 순서 일치 (1=start … 10=finish)
- page_no == 분기 10건, 각 페이지 사용자 PNG 정확히 매핑
- Jinja 변수: headline(10), body(9), cta(2), label(4), page_no(1)
2026-05-18 08:03:29 +09:00
318190c93f docs(insta-lab): design_importer는 로컬 실행 권장 — NAS docker exec 시 결과 소실 함정
docker-compose의 insta-lab volume mount는 /app/data만이라 /app/app/templates는
컨테이너 ephemeral state. NAS docker exec로 design_importer 돌리면 card.html.j2가
컨테이너 안에만 생성되고 다음 webhook rebuild에 소실됨 → 렌더러가 default 폴백.

- CLAUDE.md: "실행 위치 — 로컬 권장" 경고 + 로컬 셋업 흐름 + 응급 hotfix docker cp 패턴
- design_importer.py module docstring 동일 내용 반영

PNG 사이즈 1080×1350 → 4:5 비율 권장으로 문서 일치 (이전 검증 완화 반영).
2026-05-18 07:29:55 +09:00
c8684280af feat(insta-lab): minimal theme page_mapping을 _order.json으로 명시
기본 매핑(start→1, cta→10, 나머지 알파벳)으로는 finish.png가 page 3에
배정되는 문제 해결. 카드뉴스 자연스러운 흐름으로 명시:

1. start (인트로)
2. keyword (오늘의 키워드)
3. highlight (핵심 하이라이트)
4. observation (관찰)
5. memo (메모)
6. oneline (한 줄 정리)
7. checklist (체크리스트)
8. study (심화)
9. cta (액션 유도)
10. finish (마감)

다음 design_importer 실행 시 이 매핑이 우선 적용됨.
2026-05-18 00:55:22 +09:00
6895e2f8dc fix(insta-lab): design_importer dimension 검증을 4:5 비율로 완화
운영에서 사용자 디자인이 1122x1402로 작성됨. 1080x1350과 정확히 같은
4:5 종횡비지만 절대 사이즈만 다르므로 정확한 사이즈 강제는 과도.

- 검증: 종횡비 4:5 (±2% tolerance). 1080x1350·1122x1402 등 동일 비율
  높은 해상도 모두 통과.
- Vision은 base64로 원본 분석 (사이즈 무관).
- Playwright는 background-size: cover로 1080x1350 컨테이너에 자동 fit.
- 비율이 깨지면 (예: 1024x1024 정사각) 여전히 reject.

test_validate_images_accepts_higher_resolution_4_5_ratio 신규 (1 case).
2026-05-18 00:42:30 +09:00
34619dc70b fix(insta-lab): add Pillow to requirements.txt (design_importer 의존)
design_importer.py가 1080x1350 이미지 검증을 위해 `from PIL import Image`
사용. 운영 컨테이너에서 ModuleNotFoundError: No module named 'PIL' 발생.

card_renderer는 Playwright만 쓰므로 기존 requirements에 PIL이 없었음.
local pytest는 dev 환경에 Pillow가 이미 설치돼 있어 PASS — 운영 검증
구멍.

Pillow>=10 추가 → 다음 webhook 빌드 시 pip 설치.
2026-05-18 00:33:21 +09:00
47cdc43aa5 Merge pull request 'feat/insta-design-importer' (#7) from feat/insta-design-importer into main
Reviewed-on: #7
2026-05-18 00:28:52 +09:00
27 changed files with 1200 additions and 95 deletions

209
CHECK_POINT.md Normal file
View 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건. 진단 커맨드.

View File

@@ -484,16 +484,30 @@ docker compose up -d
- `agent_config.custom_config.auto_select=True`이면 카테고리당 1위 키워드 자동 슬레이트 생성·발송
**디자인 import (사용자 디자인 PNG → Claude Vision → Jinja HTML 자동 생성)**
- `insta-lab/app/templates/<theme>/pages/*.png` (10장, 1080×1350, placeholder 텍스트 박혀있는 형태) → Claude Sonnet Vision → `templates/<theme>/card.html.j2` 자동 생성
- CLI: `docker exec insta-lab python -m app.design_importer <theme>`
- `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
- 활성화: NAS `.env``INSTA_DEFAULT_THEME=<theme>` 추가 + `docker compose restart insta-lab`
- 활성화: `.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 목록**
| 메서드 | 경로 | 설명 |

View File

@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -1,12 +1,9 @@
import asyncio
import random
import time
from typing import Optional
from ..config import IDLE_BREAK_THRESHOLD, BREAK_DURATION_MIN, BREAK_DURATION_MAX
from ..db import add_log
VALID_STATES = ("idle", "working", "waiting", "reporting", "break")
VALID_STATES = ("idle", "working", "waiting", "reporting")
class BaseAgent:
agent_id: str = ""
@@ -14,7 +11,6 @@ class BaseAgent:
state: str = "idle"
state_detail: str = ""
_idle_since: float = 0.0
_break_until: float = 0.0
_ws_manager = None
def __init__(self):
@@ -32,9 +28,6 @@ class BaseAgent:
if new_state == "idle":
self._idle_since = time.time()
elif new_state == "break":
duration = random.randint(BREAK_DURATION_MIN, BREAK_DURATION_MAX)
self._break_until = time.time() + duration
add_log(self.agent_id, f"State: {old} -> {new_state} ({detail})")
@@ -48,19 +41,6 @@ class BaseAgent:
await self._ws_manager.send_notification(
self.agent_id, "task_completed", task_id, detail or "작업 완료"
)
if new_state == "break":
await self._ws_manager.send_agent_move(self.agent_id, "break_room")
elif old == "break" and new_state == "idle":
await self._ws_manager.send_agent_move(self.agent_id, "desk")
async def check_idle_break(self) -> None:
now = time.time()
if self.state == "idle" and (now - self._idle_since) > IDLE_BREAK_THRESHOLD:
if random.random() < 0.5:
break_type = random.choice(["커피 타임", "잠깐 산책", "졸고 있음"])
await self.transition("break", break_type)
elif self.state == "break" and now > self._break_until:
await self.transition("idle", "휴식 완료")
async def on_schedule(self) -> None:
raise NotImplementedError

View File

@@ -46,7 +46,7 @@ class InstaAgent(BaseAgent):
async def on_schedule(self) -> None:
"""09:30 매일: 뉴스 수집 → 키워드 추출 → 텔레그램 후보 푸시.
custom_config.auto_select=True면 카테고리당 1위 키워드 자동 슬레이트 생성."""
if self.state not in ("idle", "break"):
if self.state != "idle":
return
config = get_agent_config(self.agent_id) or {}
custom = config.get("custom_config", {}) or {}

View File

@@ -8,7 +8,7 @@ class LottoAgent(BaseAgent):
display_name = "로또 큐레이터"
async def on_schedule(self) -> None:
if self.state not in ("idle", "break"):
if self.state != "idle":
return
await self._run(source="auto")

View File

@@ -44,7 +44,7 @@ class StockAgent(BaseAgent):
display_name = "주식 트레이더"
async def on_schedule(self) -> None:
if self.state not in ("idle", "break"):
if self.state != "idle":
return
task_id = create_task(self.agent_id, "news_summary", {"limit": 15})
@@ -129,7 +129,7 @@ class StockAgent(BaseAgent):
4) status=='success' → telegram_payload.text 를 parse_mode 그대로 전송
5) 예외/실패 → 운영자에게 별도 텔레그램 알림 (HTML)
"""
if self.state not in ("idle", "break"):
if self.state != "idle":
return
task_id = create_task(self.agent_id, "screener_run", {"mode": "auto"})
@@ -243,7 +243,7 @@ class StockAgent(BaseAgent):
4) failures > 30% → 경고 알림 후 메인 메시지 발송
5) 정상 → Top 5 호재/악재 메시지 발송 (MarkdownV2)
"""
if self.state not in ("idle", "break"):
if self.state != "idle":
return
task_id = create_task(self.agent_id, "ai_news_sentiment", {})

View File

@@ -26,11 +26,6 @@ CORS_ALLOW_ORIGINS = os.getenv(
"CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080"
)
# Idle break threshold (seconds)
IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300")) # 5 min
BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60")) # 1 min
BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180")) # 3 min
# 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")

View File

@@ -5,10 +5,6 @@ from .agents import AGENT_REGISTRY
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
async def _check_idle_breaks():
for agent in AGENT_REGISTRY.values():
await agent.check_idle_break()
async def _run_stock_schedule():
agent = AGENT_REGISTRY.get("stock")
if agent:
@@ -74,10 +70,10 @@ def init_scheduler():
id="stock_ai_news_sentiment",
)
scheduler.add_job(_run_insta_schedule, "cron", hour=9, minute=30, id="insta_pipeline")
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, id="insta_trends_collect")
scheduler.add_job(_run_lotto_schedule, "cron", day_of_week="mon", hour=9, minute=0, id="lotto_curate")
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=0, id="youtube_research")
# 09:00 cron 스태거링 — Celeron 2C/2.0GHz에서 동시 실행 시 CPU 폭주 (CHECK_POINT FU-A)
scheduler.add_job(_run_insta_trends_collect, "cron", hour=9, minute=0, 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_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(_check_idle_breaks, "interval", seconds=60, id="idle_check")
scheduler.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
scheduler.start()

View File

@@ -18,7 +18,7 @@ services:
- ${RUNTIME_PATH}/data:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -48,7 +48,7 @@ services:
- ${RUNTIME_PATH}/data/stock:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -82,7 +82,7 @@ services:
- ${RUNTIME_PATH:-.}/data/videos:/app/data/videos
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -109,7 +109,7 @@ services:
- ${RUNTIME_PATH}/data/insta:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -129,7 +129,7 @@ services:
- ${RUNTIME_PATH}/data/realestate:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -170,7 +170,7 @@ services:
- realestate-lab
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -189,7 +189,7 @@ services:
- ${RUNTIME_PATH:-.}/data/personal:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -216,7 +216,7 @@ services:
- ${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: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -239,7 +239,7 @@ services:
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3
@@ -270,7 +270,7 @@ services:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
interval: 30s
interval: 60s
timeout: 5s
retries: 3

View File

@@ -23,4 +23,4 @@ RUN playwright install chromium
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -17,6 +17,59 @@ from . import db
logger = logging.getLogger(__name__)
# NAS Celeron 2C 환경에서 Chromium을 동시에 여러 인스턴스로 띄우면 CPU/메모리 폭주.
# 슬레이트 렌더는 디스크 I/O와 Chromium launch가 직렬화되어도 충분히 빠르므로
# 단일 슬롯으로 직렬화한다. (CHECK_POINT FU-C)
_RENDER_SEMAPHORE: asyncio.Semaphore | None = None
def _render_semaphore() -> asyncio.Semaphore:
global _RENDER_SEMAPHORE
if _RENDER_SEMAPHORE is None:
_RENDER_SEMAPHORE = asyncio.Semaphore(1)
return _RENDER_SEMAPHORE
# Chromium 브라우저 풀 — 매 슬레이트마다 launch 하지 않고 1개를 살려둠.
# (CHECK_POINT 중기-6) 카드 10장 렌더 시간 ~30% 단축 기대.
_PLAYWRIGHT = None
_BROWSER = None
async def init_browser() -> None:
"""앱 startup hook에서 1회 호출. 이미 살아있으면 no-op."""
global _PLAYWRIGHT, _BROWSER
if _BROWSER is not None and _BROWSER.is_connected():
return
_PLAYWRIGHT = await async_playwright().start()
_BROWSER = await _PLAYWRIGHT.chromium.launch()
logger.info("Chromium browser pool 초기화 완료")
async def shutdown_browser() -> None:
"""앱 shutdown hook에서 1회 호출."""
global _PLAYWRIGHT, _BROWSER
if _BROWSER is not None:
try:
await _BROWSER.close()
except Exception:
logger.exception("browser close 중 예외 (무시)")
_BROWSER = None
if _PLAYWRIGHT is not None:
try:
await _PLAYWRIGHT.stop()
except Exception:
logger.exception("playwright stop 중 예외 (무시)")
_PLAYWRIGHT = None
async def _get_browser():
"""현재 브라우저 핸들 반환. crashed/None이면 재초기화 후 반환."""
global _BROWSER
if _BROWSER is None or not _BROWSER.is_connected():
await init_browser()
return _BROWSER
def _resolve_template_dir() -> str:
"""Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/."""
@@ -64,6 +117,11 @@ def _build_pages(slate: dict) -> List[dict]:
async def render_slate(slate_id: int, template: str = "default/card.html.j2") -> List[str]:
async with _render_semaphore():
return await _render_slate_locked(slate_id, template)
async def _render_slate_locked(slate_id: int, template: str) -> List[str]:
slate = db.get_card_slate(slate_id)
if not slate:
raise ValueError(f"slate {slate_id} not found")
@@ -80,29 +138,28 @@ async def render_slate(slate_id: int, template: str = "default/card.html.j2") ->
out_dir = _slate_dir(slate_id)
paths: List[str] = []
async with async_playwright() as p:
browser = await p.chromium.launch()
try:
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
page = await ctx.new_page()
for spec in pages:
html_str = tmpl.render(**spec)
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
f.write(html_str)
html_path = f.name
browser = await _get_browser()
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
try:
page = await ctx.new_page()
for spec in pages:
html_str = tmpl.render(**spec)
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
f.write(html_str)
html_path = f.name
try:
await page.goto(f"file://{html_path}", wait_until="networkidle")
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
await page.screenshot(path=out_path, full_page=False, omit_background=False)
with open(out_path, "rb") as fp:
file_hash = hashlib.md5(fp.read()).hexdigest()
db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash)
paths.append(out_path)
finally:
try:
await page.goto(f"file://{html_path}", wait_until="networkidle")
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
await page.screenshot(path=out_path, full_page=False, omit_background=False)
with open(out_path, "rb") as fp:
file_hash = hashlib.md5(fp.read()).hexdigest()
db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash)
paths.append(out_path)
finally:
try:
os.unlink(html_path)
except OSError:
pass
finally:
await browser.close()
os.unlink(html_path)
except OSError:
pass
finally:
await ctx.close()
return paths

View File

@@ -1,6 +1,19 @@
"""사용자 디자인 PNG 10장 → Claude Sonnet Vision → Jinja card.html.j2 자동 생성.
CLI (이 phase 이후 추가): python -m app.design_importer <theme_name>
⚠️ 실행 위치 — 로컬 권장:
docker-compose의 insta-lab volume은 /app/data만 마운트. /app/app/templates는
컨테이너 ephemeral이라 NAS docker exec로 돌리면 다음 rebuild에 결과물 소실됨.
로컬:
cd insta-lab
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 + push
응급 hotfix만 NAS:
docker exec insta-lab python -m app.design_importer <theme>
docker cp insta-lab:/app/app/templates/<theme>/card.html.j2 ./<dst>
# → 즉시 host repo에 commit + push (안 그러면 다음 rebuild에 소실)
"""
import base64
@@ -102,8 +115,16 @@ def _build_mapping(pngs: List[str]) -> Dict[str, int]:
return mapping
_EXPECTED_RATIO = 1080 / 1350 # 4:5 = 0.8
_RATIO_TOLERANCE = 0.02 # ±2% (1122/1402 ≈ 0.80028도 통과)
def _validate_images(pages_dir: Path) -> None:
"""모든 PNG가 정확히 1080×1350인지 검증. 다르면 ValueError.
"""모든 PNG가 4:5 종횡비(1080x1350 권장)에 가까운지 검증.
Vision은 base64로 원본을 분석하고 Playwright는 background-size: cover로
1080x1350 컨테이너에 fit하므로 절대 사이즈는 유연. 단 종횡비가 어긋나면
카드가 늘어나거나 잘리므로 ±2% 허용 범위 내에서만 통과.
early-exit 하지 않고 전체 파일을 검사한 뒤 한 메시지에 모아 raise.
"""
@@ -111,12 +132,17 @@ def _validate_images(pages_dir: Path) -> None:
bad = []
for png_path in sorted(pages_dir.glob("*.png")):
with Image.open(png_path) as img:
if img.size != _EXPECTED_SIZE:
w, h = img.size
if h == 0:
bad.append((png_path.name, img.size))
continue
ratio = w / h
if abs(ratio - _EXPECTED_RATIO) > _RATIO_TOLERANCE:
bad.append((png_path.name, img.size))
if bad:
msg = "; ".join(f"{n}: {s[0]}x{s[1]}" for n, s in bad)
raise ValueError(
f"모든 카드 디자인은 1080x1350이어야 함. 잘못된 파일: {msg}"
f"카드 디자인은 4:5 비율(1080x1350 권장)이어야 함. 잘못된 파일: {msg}"
)

View File

@@ -31,9 +31,16 @@ app.add_middleware(
@app.on_event("startup")
def on_startup():
async def on_startup():
os.makedirs(INSTA_DATA_PATH, exist_ok=True)
db.init_db()
# Chromium browser pool 초기화 (CHECK_POINT 중기-6)
await card_renderer.init_browser()
@app.on_event("shutdown")
async def on_shutdown():
await card_renderer.shutdown_browser()
@app.get("/health")

View File

@@ -0,0 +1,788 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hedgy Card News {{ page_no }}/10</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #d0d0d0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: 'Noto Sans KR', sans-serif;
letter-spacing: -0.02em;
line-height: 1.3;
}
.card {
position: relative;
width: 1080px;
height: 1350px;
overflow: hidden;
border-radius: 48px;
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
}
/* ── shared overlay layer ── */
.mask {
position: absolute;
word-wrap: break-word;
overflow: hidden;
}
/* ═══════════════════════════════════════════
PAGE 1 insta_card_start.png
bg: #f2f2f0 (light warm white)
═══════════════════════════════════════════ */
.p1-headline-mask {
top: 222px; left: 48px;
width: 580px; height: 150px;
background: #f2f2f0;
padding: 8px;
}
.p1-headline-text {
position: absolute;
top: 222px; left: 48px;
width: 580px; height: 150px;
padding: 8px;
font-size: 108px;
font-weight: 900;
color: #1e2235;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
.p1-body-mask {
top: 400px; left: 48px;
width: 460px; height: 120px;
background: #f2f2f0;
padding: 8px;
}
.p1-body-text {
position: absolute;
top: 400px; left: 48px;
width: 460px; height: 120px;
padding: 8px;
font-size: 34px;
font-weight: 500;
color: #4a4e5e;
word-wrap: break-word;
overflow: hidden;
}
.p1-cta-mask {
top: 562px; left: 48px;
width: 260px; height: 76px;
background: #2f6ef7;
border-radius: 38px;
padding: 8px;
}
.p1-cta-text {
position: absolute;
top: 562px; left: 48px;
width: 260px; height: 76px;
border-radius: 38px;
padding: 8px 24px;
font-size: 34px;
font-weight: 700;
color: #ffffff;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
/* ═══════════════════════════════════════════
PAGE 2 insta_card_keyword.png
bg: #3a3fdb (blue gradient)
═══════════════════════════════════════════ */
.p2-headline-mask {
top: 148px; left: 56px;
width: 880px; height: 200px;
background: #3a3fdb;
padding: 8px;
}
.p2-headline-text {
position: absolute;
top: 148px; left: 56px;
width: 880px; height: 200px;
padding: 8px;
font-size: 88px;
font-weight: 900;
color: #ffffff;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
.p2-body-mask {
top: 370px; left: 56px;
width: 880px; height: 80px;
background: #3a3fdb;
padding: 8px;
}
.p2-body-text {
position: absolute;
top: 370px; left: 56px;
width: 880px; height: 80px;
padding: 8px;
font-size: 38px;
font-weight: 500;
color: #e0e4ff;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
/* ═══════════════════════════════════════════
PAGE 3 insta_card_highlight.png
bg: #3a3fdb
═══════════════════════════════════════════ */
.p3-headline-mask {
top: 148px; left: 56px;
width: 880px; height: 260px;
background: #3a3fdb;
padding: 8px;
}
.p3-headline-text {
position: absolute;
top: 148px; left: 56px;
width: 880px; height: 260px;
padding: 8px;
font-size: 88px;
font-weight: 900;
color: #ffffff;
word-wrap: break-word;
overflow: hidden;
}
.p3-body-mask {
top: 430px; left: 56px;
width: 880px; height: 80px;
background: #3a3fdb;
padding: 8px;
}
.p3-body-text {
position: absolute;
top: 430px; left: 56px;
width: 880px; height: 80px;
padding: 8px;
font-size: 38px;
font-weight: 500;
color: #e0e4ff;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
/* ═══════════════════════════════════════════
PAGE 4 insta_card_observation.png
bg: #f2f2f0
═══════════════════════════════════════════ */
.p4-label-mask {
top: 72px; left: 64px;
width: 200px; height: 52px;
background: #f2f2f0;
padding: 8px;
}
.p4-label-text {
position: absolute;
top: 72px; left: 64px;
width: 200px; height: 52px;
padding: 8px;
font-size: 32px;
font-weight: 700;
color: #2f6ef7;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
.p4-headline-mask {
top: 148px; left: 56px;
width: 700px; height: 110px;
background: #f2f2f0;
padding: 8px;
}
.p4-headline-text {
position: absolute;
top: 148px; left: 56px;
width: 700px; height: 110px;
padding: 8px;
font-size: 72px;
font-weight: 900;
color: #1e2235;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
.p4-body-mask {
top: 290px; left: 56px;
width: 700px; height: 180px;
background: #f2f2f0;
padding: 8px;
}
.p4-body-text {
position: absolute;
top: 290px; left: 56px;
width: 700px; height: 180px;
padding: 8px;
font-size: 36px;
font-weight: 400;
color: #3a3e50;
word-wrap: break-word;
overflow: hidden;
}
/* ═══════════════════════════════════════════
PAGE 5 insta_card_memo.png
bg: #f2f2f0
═══════════════════════════════════════════ */
.p5-label-mask {
top: 72px; left: 64px;
width: 200px; height: 52px;
background: #f2f2f0;
padding: 8px;
}
.p5-label-text {
position: absolute;
top: 72px; left: 64px;
width: 200px; height: 52px;
padding: 8px;
font-size: 32px;
font-weight: 700;
color: #2f6ef7;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
.p5-headline-mask {
top: 160px; left: 56px;
width: 700px; height: 110px;
background: #f2f2f0;
padding: 8px;
}
.p5-headline-text {
position: absolute;
top: 160px; left: 56px;
width: 700px; height: 110px;
padding: 8px;
font-size: 70px;
font-weight: 900;
color: #1e2235;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
.p5-body-mask {
top: 308px; left: 56px;
width: 700px; height: 180px;
background: #f2f2f0;
padding: 8px;
}
.p5-body-text {
position: absolute;
top: 308px; left: 56px;
width: 700px; height: 180px;
padding: 8px;
font-size: 34px;
font-weight: 400;
color: #3a3e50;
word-wrap: break-word;
overflow: hidden;
}
/* ═══════════════════════════════════════════
PAGE 6 insta_card_oneline.png
bg: #f5f4f2
═══════════════════════════════════════════ */
.p6-headline-mask {
top: 188px; left: 96px;
width: 820px; height: 240px;
background: #f5f4f2;
padding: 8px;
}
.p6-headline-text {
position: absolute;
top: 188px; left: 96px;
width: 820px; height: 240px;
padding: 8px;
font-size: 68px;
font-weight: 900;
color: #1e2235;
word-wrap: break-word;
overflow: hidden;
}
.p6-body-mask {
top: 448px; left: 96px;
width: 620px; height: 120px;
background: #f5f4f2;
padding: 8px;
}
.p6-body-text {
position: absolute;
top: 448px; left: 96px;
width: 620px; height: 120px;
padding: 8px;
font-size: 34px;
font-weight: 400;
color: #5a5e70;
word-wrap: break-word;
overflow: hidden;
}
/* ═══════════════════════════════════════════
PAGE 7 insta_card_checklist.png
bg: #f5f4f2
═══════════════════════════════════════════ */
.p7-headline-mask {
top: 110px; left: 56px;
width: 740px; height: 110px;
background: #f5f4f2;
padding: 8px;
}
.p7-headline-text {
position: absolute;
top: 110px; left: 56px;
width: 740px; height: 110px;
padding: 8px;
font-size: 74px;
font-weight: 900;
color: #1e2235;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
/* checklist items 4 rows */
.p7-item1-mask { top: 258px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
.p7-item1-text { position: absolute; top: 258px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
.p7-item2-mask { top: 388px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
.p7-item2-text { position: absolute; top: 388px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
.p7-item3-mask { top: 518px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
.p7-item3-text { position: absolute; top: 518px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
.p7-item4-mask { top: 648px; left: 164px; width: 720px; height: 80px; background: #f5f4f2; padding: 8px; }
.p7-item4-text { position: absolute; top: 648px; left: 164px; width: 720px; height: 80px; padding: 8px; font-size: 40px; font-weight: 500; color: #2a2d3e; word-wrap: break-word; overflow: hidden; display: flex; align-items: center; }
/* ═══════════════════════════════════════════
PAGE 8 insta_card_study.png
bg: #f2f2f0
═══════════════════════════════════════════ */
.p8-label-mask {
top: 72px; left: 64px;
width: 200px; height: 52px;
background: #f2f2f0;
padding: 8px;
}
.p8-label-text {
position: absolute;
top: 72px; left: 64px;
width: 200px; height: 52px;
padding: 8px;
font-size: 32px;
font-weight: 700;
color: #2f6ef7;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
.p8-headline-mask {
top: 160px; left: 56px;
width: 700px; height: 110px;
background: #f2f2f0;
padding: 8px;
}
.p8-headline-text {
position: absolute;
top: 160px; left: 56px;
width: 700px; height: 110px;
padding: 8px;
font-size: 72px;
font-weight: 900;
color: #1e2235;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
.p8-body-mask {
top: 306px; left: 56px;
width: 700px; height: 180px;
background: #f2f2f0;
padding: 8px;
}
.p8-body-text {
position: absolute;
top: 306px; left: 56px;
width: 700px; height: 180px;
padding: 8px;
font-size: 34px;
font-weight: 400;
color: #3a3e50;
word-wrap: break-word;
overflow: hidden;
}
/* ═══════════════════════════════════════════
PAGE 9 insta_card_cta.png
bg: #f5f4f2
═══════════════════════════════════════════ */
.p9-headline-mask {
top: 182px; left: 56px;
width: 970px; height: 120px;
background: #f5f4f2;
padding: 8px;
}
.p9-headline-text {
position: absolute;
top: 182px; left: 56px;
width: 970px; height: 120px;
padding: 8px;
font-size: 82px;
font-weight: 900;
color: #1e2235;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.p9-cta-mask {
top: 332px; left: 180px;
width: 720px; height: 88px;
background: #2244cc;
border-radius: 44px;
padding: 8px;
}
.p9-cta-text {
position: absolute;
top: 332px; left: 180px;
width: 720px; height: 88px;
border-radius: 44px;
padding: 8px;
font-size: 42px;
font-weight: 700;
color: #ffffff;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.p9-body-mask {
top: 980px; left: 56px;
width: 860px; height: 60px;
background: #f5f4f2;
padding: 8px;
}
.p9-body-text {
position: absolute;
top: 980px; left: 56px;
width: 860px; height: 60px;
padding: 8px;
font-size: 30px;
font-weight: 400;
color: #5a5e70;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
/* ═══════════════════════════════════════════
PAGE 10 insta_card_finish.png
bg: #f2f2f0
═══════════════════════════════════════════ */
.p10-label-mask {
top: 72px; left: 64px;
width: 200px; height: 52px;
background: #f2f2f0;
padding: 8px;
}
.p10-label-text {
position: absolute;
top: 72px; left: 64px;
width: 200px; height: 52px;
padding: 8px;
font-size: 32px;
font-weight: 700;
color: #2f6ef7;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
.p10-headline-mask {
top: 155px; left: 56px;
width: 700px; height: 110px;
background: #f2f2f0;
padding: 8px;
}
.p10-headline-text {
position: absolute;
top: 155px; left: 56px;
width: 700px; height: 110px;
padding: 8px;
font-size: 72px;
font-weight: 900;
color: #1e2235;
word-wrap: break-word;
overflow: hidden;
display: flex;
align-items: center;
}
.p10-body-mask {
top: 302px; left: 56px;
width: 680px; height: 180px;
background: #f2f2f0;
padding: 8px;
}
.p10-body-text {
position: absolute;
top: 302px; left: 56px;
width: 680px; height: 180px;
padding: 8px;
font-size: 34px;
font-weight: 400;
color: #3a3e50;
word-wrap: break-word;
overflow: hidden;
}
/* checklist icon (page 7) */
.check-icon {
position: absolute;
width: 76px; height: 76px;
background: #3366ee;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.check-icon svg { width: 44px; height: 44px; }
/* quote mark (page 2 & 3) */
.quote-mark {
position: absolute;
font-size: 100px;
font-weight: 900;
color: #ffffff;
line-height: 1;
opacity: 0.95;
}
/* left bar (page 6) */
.left-bar {
position: absolute;
top: 196px; left: 64px;
width: 10px; height: 232px;
background: #7c5ce0;
border-radius: 5px;
}
</style>
</head>
<body>
{% if page_no == 1 %}
<!-- ══════════════════════════════════════
PAGE 1 · COVER · insta_card_start.png
══════════════════════════════════════ -->
<div class="card" style="background-image: url('pages/insta_card_start.png');">
<!-- headline mask + text -->
<div class="mask p1-headline-mask"></div>
<div class="mask p1-headline-text">{{ headline }}</div>
<!-- body mask + text -->
<div class="mask p1-body-mask"></div>
<div class="mask p1-body-text">{{ body }}</div>
<!-- cta mask + text -->
<div class="mask p1-cta-mask"></div>
<div class="mask p1-cta-text">{{ cta }}</div>
</div>
{% endif %}
{% if page_no == 2 %}
<!-- ══════════════════════════════════════
PAGE 2 · insta_card_keyword.png
══════════════════════════════════════ -->
<div class="card" style="background-image: url('pages/insta_card_keyword.png');">
<!-- quote mark mask -->
<div class="mask" style="top:60px;left:48px;width:120px;height:100px;background:#3a3fdb;padding:8px;"></div>
<div class="quote-mark" style="top:52px;left:50px;">"</div>
<!-- headline -->
<div class="mask p2-headline-mask"></div>
<div class="mask p2-headline-text">{{ headline }}</div>
<!-- body -->
<div class="mask p2-body-mask"></div>
<div class="mask p2-body-text">{{ body }}</div>
</div>
{% endif %}
{% if page_no == 3 %}
<!-- ══════════════════════════════════════
PAGE 3 · insta_card_highlight.png
══════════════════════════════════════ -->
<div class="card" style="background-image: url('pages/insta_card_highlight.png');">
<!-- quote mark mask -->
<div class="mask" style="top:60px;left:48px;width:120px;height:100px;background:#3a3fdb;padding:8px;"></div>
<div class="quote-mark" style="top:52px;left:50px;">"</div>
<!-- headline -->
<div class="mask p3-headline-mask"></div>
<div class="mask p3-headline-text">{{ headline }}</div>
<!-- body -->
<div class="mask p3-body-mask"></div>
<div class="mask p3-body-text">{{ body }}</div>
</div>
{% endif %}
{% if page_no == 4 %}
<!-- ══════════════════════════════════════
PAGE 4 · insta_card_observation.png
══════════════════════════════════════ -->
<div class="card" style="background-image: url('pages/insta_card_observation.png');">
<!-- day label -->
<div class="mask p4-label-mask"></div>
<div class="mask p4-label-text">{{ label }}</div>
<!-- headline -->
<div class="mask p4-headline-mask"></div>
<div class="mask p4-headline-text">{{ headline }}</div>
<!-- body -->
<div class="mask p4-body-mask"></div>
<div class="mask p4-body-text">{{ body }}</div>
</div>
{% endif %}
{% if page_no == 5 %}
<!-- ══════════════════════════════════════
PAGE 5 · insta_card_memo.png
══════════════════════════════════════ -->
<div class="card" style="background-image: url('pages/insta_card_memo.png');">
<!-- day label -->
<div class="mask p5-label-mask"></div>
<div class="mask p5-label-text">{{ label }}</div>
<!-- headline -->
<div class="mask p5-headline-mask"></div>
<div class="mask p5-headline-text">{{ headline }}</div>
<!-- body -->
<div class="mask p5-body-mask"></div>
<div class="mask p5-body-text">{{ body }}</div>
</div>
{% endif %}
{% if page_no == 6 %}
<!-- ══════════════════════════════════════
PAGE 6 · insta_card_oneline.png
══════════════════════════════════════ -->
<div class="card" style="background-image: url('pages/insta_card_oneline.png');">
<!-- purple left bar -->
<div class="left-bar"></div>
<!-- headline -->
<div class="mask p6-headline-mask"></div>
<div class="mask p6-headline-text">{{ headline }}</div>
<!-- body -->
<div class="mask p6-body-mask"></div>
<div class="mask p6-body-text">{{ body }}</div>
</div>
{% endif %}
{% if page_no == 7 %}
<!-- ══════════════════════════════════════
PAGE 7 · insta_card_checklist.png
══════════════════════════════════════ -->
<div class="card" style="background-image: url('pages/insta_card_checklist.png');">
<!-- section title -->
<div class="mask p7-headline-mask"></div>
<div class="mask p7-headline-text">{{ headline }}</div>
<!-- check icons -->
<div class="check-icon" style="top:252px;left:56px;">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<div class="check-icon" style="top:382px;left:56px;">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<div class="check-icon" style="top:512px;left:56px;">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<div class="check-icon" style="top:642px;left:56px;">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<!-- checklist items -->
<div class="mask p7-item1-mask"></div>
<div class="mask p7-item1-text">{{ item1 }}</div>
<div class="mask p7-item2-mask"></div>
<div class="mask p7-item2-text">{{ item2 }}</div>
<div class="mask p7-item3-mask"></div>
<div class="mask p7-item3-text">{{ item3 }}</div>
<div class="mask p7-item4-mask"></div>
<div class="mask p7-item4-text">{{ item4 }}</div>
</div>
{% endif %}
{% if page_no == 8 %}
<!-- ══════════════════════════════════════
PAGE 8 · insta_card_study.png
══════════════════════════════════════ -->
<div class="card" style="background-image: url('pages/insta_card_study.png');">
<!-- day label -->
<div class="mask p8-label-mask"></div>
<div class="mask p8-label-text">{{ label }}</div>
<!-- headline -->
<div class="mask p8-headline-mask"></div>
<div class="mask p8-headline-text">{{ headline }}</div>
<!-- body -->
<div class="mask p8-body-mask"></div>
<div class="mask p8-body-text">{{ body }}</div>
</div>
{% endif %}
{% if page_no == 9 %}
<!-- ══════════════════════════════════════
PAGE 9 · insta_card_cta.png
══════════════════════════════════════ -->
<div class="card" style="background-image: url('pages/insta_card_cta.png');">
<!-- headline -->
<div class="mask p9-headline-mask"></div>
<div class="mask p9-headline-text">{{ headline }}</div>
<!-- cta button -->
<div class="mask p9-cta-mask"></div>
<div class="mask p9-cta-text">{{ cta }}</div>
<!-- body / next episode teaser -->
<div class="mask p9-body-mask"></div>
<div class="mask p9-body-text">{{ body }}</div>
</div>
{% endif %}
{% if page_no == 10 %}
<!-- ══════════════════════════════════════
PAGE 10 · insta_card_finish.png
══════════════════════════════════════ -->
<div class="card" style="background-image: url('pages/insta_card_finish.png');">
<!-- day label -->
<div class="mask p10-label-mask"></div>
<div class="mask p10-label-text">{{ label }}</div>
<!-- headline -->
<div class="mask p10-headline-mask"></div>
<div class="mask p10-headline-text">{{ headline }}</div>
<!-- body -->
<div class="mask p10-body-mask"></div>
<div class="mask p10-body-text">{{ body }}</div>
</div>
{% endif %}
</body>
</html>

View File

@@ -0,0 +1,12 @@
{
"insta_card_start.png": 1,
"insta_card_keyword.png": 2,
"insta_card_highlight.png": 3,
"insta_card_observation.png": 4,
"insta_card_memo.png": 5,
"insta_card_oneline.png": 6,
"insta_card_checklist.png": 7,
"insta_card_study.png": 8,
"insta_card_cta.png": 9,
"insta_card_finish.png": 10
}

View File

@@ -5,5 +5,6 @@ httpx>=0.27
anthropic==0.52.0
jinja2>=3.1.4
playwright==1.48.0
Pillow>=10
pytest>=8.0
pytest-asyncio>=0.24

View File

@@ -86,6 +86,14 @@ def _make_png(path: Path, size: tuple[int, int]) -> None:
Image.new("RGB", size, color=(200, 200, 200)).save(path, format="PNG")
def test_validate_images_accepts_higher_resolution_4_5_ratio(tmp_theme):
"""1080x1350 외에도 같은 4:5 비율이면 통과 (예: 1122x1402, 디자인 도구 export 흔한 사이즈)."""
pages = tmp_theme / "pages"
for i in range(10):
_make_png(pages / f"insta_card_{i:02d}.png", (1122, 1402))
design_importer._validate_images(pages) # 예외 없으면 통과
def test_validate_images_accepts_1080x1350(tmp_theme):
pages = tmp_theme / "pages"
for i in range(10):

View File

@@ -15,7 +15,7 @@ ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION

View File

@@ -83,7 +83,8 @@ def on_startup():
def _run_simulation_job():
run_simulation(n_candidates=20000, top_k=100, best_n=20)
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
# stock 08:00 cron과 분리하기 위해 minute=5 → 30 (CHECK_POINT FU-B)
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=30)
# 3. 토요일 오전 9시 — 다음 회차 공략 리포트 자동 캐싱
def _save_weekly_report_job():

View File

@@ -15,4 +15,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -15,4 +15,4 @@ ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -1,6 +1,7 @@
import os
import logging
import threading
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from fastapi import BackgroundTasks, FastAPI, Query, HTTPException
from fastapi.middleware.cors import CORSMiddleware
@@ -26,10 +27,19 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
def scheduled_collect():
"""매일 09:00 — 수집 + 정리 + 매칭 + 알림 push"""
"""매일 09:15 — 수집 + 정리 (병렬) → 매칭 알림 push.
collect_all과 delete_old_completed_announcements는 서로 다른 데이터
영역을 건드리므로 thread 둘로 병렬화. 매칭은 두 작업 완료 후 순차
실행 (DB 일관성). CHECK_POINT 중기-8 — env이 BackgroundScheduler+
동기 함수 조합이라 asyncio.gather 대신 ThreadPoolExecutor 사용.
"""
logger.info("스케줄 수집 시작")
collect_all()
deleted = delete_old_completed_announcements(grace_days=90)
with ThreadPoolExecutor(max_workers=2) as ex:
collect_future = ex.submit(collect_all)
delete_future = ex.submit(delete_old_completed_announcements, 90)
collect_future.result()
deleted = delete_future.result()
if deleted:
logger.info("정리: %d건 삭제", deleted)
run_matching()
@@ -48,7 +58,8 @@ def scheduled_status_update():
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=0, id="collect")
# 09:00 cron 스태거링 — agent-office 09:00/05/10 이후 (CHECK_POINT FU-A)
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, id="collect")
scheduler.add_job(scheduled_status_update, "cron", hour=0, minute=0, id="status_update")
scheduler.start()
logger.info("realestate-lab 시작")

View File

@@ -6,4 +6,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -19,7 +19,7 @@ EXPOSE 8000
ENV PYTHONUNBUFFERED=1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION