Compare commits
78 Commits
796ac6d39f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a020c52a30 | |||
| b86fb874f0 | |||
| f676116336 | |||
| 4097c95286 | |||
| b471d2c455 | |||
| 8e28ce9ae5 | |||
| 28418b9f5d | |||
| 1fff511752 | |||
| 04081bef6a | |||
| 54654af815 | |||
| 13e3e558af | |||
| f931c496d8 | |||
| 008111eff8 | |||
| dfda38bd8e | |||
| e09e11be7b | |||
| cdc309150e | |||
| a7be8f76bf | |||
| 95fadaa8ef | |||
| c6b969443f | |||
| b4fb3998fe | |||
| 4847626424 | |||
| 08ac800910 | |||
| d752675e9d | |||
| c0a50f4ee6 | |||
| cd15504f86 | |||
| e8998a4098 | |||
| b8229c0ffa | |||
| 9baea3a0e2 | |||
| 80daa53558 | |||
| 35795abb0f | |||
| 4e47f5dd43 | |||
| 246c8d5328 | |||
| ed17193945 | |||
| c4b2fffeb4 | |||
| c6540b2417 | |||
| 2bce07c367 | |||
| 2906a2ae3e | |||
| 134b9e5d07 | |||
| bf84328d59 | |||
| d8b3267b98 | |||
| 89c52b1fb6 | |||
| 01a8aee226 | |||
| b2c4ca0e0b | |||
| baa3a3075d | |||
| 4cb9dc6a7c | |||
| 36e8d11060 | |||
| db6fed72b3 | |||
| 7cce5c422f | |||
| 94beecbfaf | |||
| 98b17f3a3a | |||
| 94cddccaa7 | |||
| b49cc14ef3 | |||
| 5d5ff27d29 | |||
| 2a0090a1d4 | |||
| ea1f0d103d | |||
| a3ae85cde1 | |||
| 363e95c5a9 | |||
| c69b18243b | |||
| f0fad05f2d | |||
| ed8ffdf343 | |||
| c7036212e2 | |||
| 756d9fccf3 | |||
| ea5cf49cea | |||
| d07a8dad76 | |||
| d74bc189b5 | |||
| d4405204f9 | |||
| 2c157334dc | |||
| d840859fc9 | |||
| e115eee159 | |||
| fc1ebf134d | |||
| d71937b6ee | |||
| 0cc4505af7 | |||
| 9c18f0a467 | |||
| 8212a51f90 | |||
| 0d466b235c | |||
| 1129600341 | |||
| 2a0a2f3490 | |||
| 56d0f5b8a8 |
9
.mcp.json
Normal file
9
.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"co-gahusb": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
244
CHECK_POINT.md
244
CHECK_POINT.md
@@ -1,209 +1,121 @@
|
|||||||
# web-backend CHECK_POINT
|
# web-backend CHECK_POINT
|
||||||
|
|
||||||
> NAS Docker 11 컨테이너(9 백엔드 + frontend + deployer). Synology Celeron J4025 (2C 2.0GHz) 18GB.
|
> NAS Docker (Synology Celeron J4025 2C 2.0GHz, 18GB). 16+ 컨테이너(14 서비스 + Redis + frontend + deployer).
|
||||||
> 2026-05-18 작성 — uvicorn CPU 폭주 진단 결과 정리.
|
> 2026-06-12 갱신 — 5/18 CPU 진단·NAS↔Windows 분산부터 6/12 음악 파이프라인 신뢰성까지 반영.
|
||||||
|
> 운영 세부(DB·스케줄러·env·함정)는 `memory/service_<name>.md`가 authoritative. 이 파일은 **무엇이 끝났고 다음에 뭘 하나**의 보드.
|
||||||
## 🔴 즉시 (오늘, 총 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) ⭐
|
## ✅ 완료 타임라인 (5/18 → 6/12)
|
||||||
|
|
||||||
**파일**: `insta-lab/app/main.py` (모듈 레벨 추가)
|
### 5/18~22 — CPU 진단 + NAS↔Windows 분산 + 로또 자율화
|
||||||
```python
|
- **CPU 폭주 즉시 5건**: 09:00 cron 5분 스태거링(insta/lotto/youtube/realestate) · lotto Monte Carlo 08:30 이동 · insta Playwright Semaphore(1) · healthcheck 60s · uvicorn `--workers 1` · realestate 수집 병렬화
|
||||||
import asyncio
|
- **Redis 분산** (박재오 7결정): Redis 컨테이너 신설(7-alpine 256MB AOF) · insta/music/video-lab을 `queue:*-render` push 게이트웨이로 전환(렌더는 Windows web-ai 워커) · internal webhook + nginx 3-layer 차단 · stock webai_cache TTLCache
|
||||||
|
- **video-lab 신설** (18801) — Windows video-render의 NAS 짝 (sora/veo/kling/seedance)
|
||||||
|
- **로또 능동 시그널 v1** — lotto_signals/baselines, z-score, urgent/digest 텔레그램, cron 4종
|
||||||
|
- **weight-evolver 자율 학습 v2** — weight_trials/auto_picks, 주간 generate→apply→evaluate 루프
|
||||||
|
|
||||||
# 모듈 레벨에 한 번만 선언
|
### 5/25~26 — tarot/saju 분리·신설 + UI
|
||||||
RENDER_SEMAPHORE = asyncio.Semaphore(1) # Chromium 동시 실행 1개로 제한
|
- **tarot-lab 분리** (18250) — agent-office에서 독립, Claude 3-card
|
||||||
|
- **saju-lab 신설** (18300) — saju-web TS→Python 포팅, lunar↔solar 내장, 궁합 포함
|
||||||
|
- **saju UI v1 + v2 리디자인** + fortune_scores/lucky/monthly_flow 추가
|
||||||
|
- image-lab public gateway + `/media/image/` 정적 서빙 · tarot max_tokens 2800 truncation fix
|
||||||
|
|
||||||
# 카드 렌더 백그라운드 함수에 감싸기
|
### 5/28 — 공유 로그 인프라
|
||||||
async def _bg_render(task_id: str, slate_id: int):
|
- **`_shared/access_log` 공용 모듈** (lotto/stock/music/insta/realestate 5종) — ring buffer + middleware + `/logs/recent`
|
||||||
async with RENDER_SEMAPHORE:
|
- agent-office `/agents/{id}/logs`가 서비스 로그 merge · 매일 03:00 agent_logs 90일 retention
|
||||||
await card_renderer.render_slate(slate_id, ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] card_renderer.render_slate를 Semaphore(1)로 감쌈 (2026-05-18, lazy init)
|
### 5/31 — 자율 인텔리전스 2종 (스마트에이전트 1·2번)
|
||||||
- [ ] 동시 2개 요청 테스트 (curl 동시 2회 → 순차 처리되는지 확인)
|
- **로또 자가학습 백테스트·캘리브레이션 v3** — backtest_runs/winner_calibration, forward 가상구매 3전략, ε-게이팅 lift 학습, 일요회고 cron. 역대 캘리브레이션 백필 1197/1197 (6/11)
|
||||||
|
- **주식 보유종목 인텔리전스** — holdings_signals, market_events/news_issues/portfolio_health, decide_action 매트릭스, EOD(16:50)+브리핑(08:30) cron
|
||||||
|
|
||||||
|
### 6/01~06 — 보안 + 인스타 카드뉴스
|
||||||
|
- nginx CVE 대응 (CVE-2026-42945 · CVE-2026-9256 → 1.30.2)
|
||||||
|
- **인스타 카드뉴스 품질 고도화 v2** + zip 패키지(10 PNG + caption.txt) + 글자수 가이드
|
||||||
|
|
||||||
|
### 6/11 — 자율 발급 + 오버사이트 (스마트에이전트 3번)
|
||||||
|
- **인스타 자율 카드 발급** — 4신호 선별(selection.py) + Claude Haiku 카드가치 판단 + 승인 게이트 + 발행 상태머신. 텔레그램 issue_approve/reject/regen 콜백. **autonomous_issue 기본 OFF**
|
||||||
|
- **에이전트 횡단 오버사이트(백엔드)** — `GET /api/agent-office/activity` 통합 피드 + 필터(agent_id/type/status/days). main `2c2828c` 배포
|
||||||
|
- CLAUDE.md 카탈로그 슬림화(966→484, 서비스별 메모리 분담) · packs jti SQLite 영속화 · lotto deep CuratorError fallthrough fix
|
||||||
|
|
||||||
|
### 6/12 — 음악 파이프라인 신뢰성·복구 (직전 작업)
|
||||||
|
- **자동 재시도**: orchestrator step 3회 backoff 재시도(publish 제외 — 업로드 비멱등)
|
||||||
|
- **수동 재개**: `POST /api/music/pipeline/{id}/retry` — 실패 step 판별·재개, retrying 레이스 가드, publish+업로드완료 시 409
|
||||||
|
- **실패 알림**: agent-office youtube_publisher가 신규 failed 감지 → 텔레그램 `⚠️실패` + `[🔄재시도]` 인라인 버튼 → music-lab retry 프록시
|
||||||
|
- 커밋·push·자동배포 완료 (main = origin/main)
|
||||||
|
|
||||||
|
> **스마트에이전트 3종 전부 가동**: stock(보유종목) · insta(자율발급) · lotto(진화). CEO 오버사이트(통합 활동 피드) 백엔드 완료.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. healthcheck interval 60s
|
## 🔴 즉시 — 진행 중 / 대기
|
||||||
|
|
||||||
**파일**: `docker-compose.yml` (모든 9 컨테이너)
|
### 1. ✅ agent oversight 프론트 NAS 배포 — 완료 (2026-06-12)
|
||||||
```yaml
|
- web-ui `ActivityTimeline`(AgentOffice 우측 기본 패널) main 머지(`d0bf5fd`) → NAS 라이브 반영·검증 완료 (index.html 갱신 + AgentOffice 번들 nginx 200)
|
||||||
# 변경 전
|
- **배포 방법**: Z: 매핑이 `!` TTY로 안 돼서 **SSH 직접 배포**(`bgg8988@gahusb.synology.me:2300`, tar + `scp -O` → assets 교체). Synology SFTP off라 `scp -O` 필수, images/videos는 불변이라 미러 제외. 상세 → `memory/feedback_windows_frontend_ssh_deploy.md`
|
||||||
healthcheck:
|
|
||||||
interval: 30s
|
|
||||||
|
|
||||||
# 변경 후
|
### 2. 운영 검증 (분산·자율 학습)
|
||||||
healthcheck:
|
- [ ] Redis 분산 E2E (NAS push → Windows 워커 → webhook 전체 흐름)
|
||||||
interval: 60s
|
- [ ] lotto weight-evolver 주간 사이클(월 generate+apply → 토 evaluate) 정상 동작 + evolution report 텔레그램(토 22:15)
|
||||||
```
|
|
||||||
|
|
||||||
- [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**:
|
### Video Studio 프론트 `/studio` — 백엔드 완료, UI 미구현
|
||||||
```dockerfile
|
- **백엔드 완료·배포**: image-lab(NAS 18802) ✅ + image-render(Windows web-ai) ✅ + video-lab(기존) ✅ (`plans/2026-05-23-video-studio-backend.md` 전부)
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
- **빠진 것**: web-ui React Flow 노드 캔버스(ImageGenNode → ImageToVideoNode). 백엔드 plan이 "프론트는 Plan 2"로 미뤘으나 Plan 2 미생성
|
||||||
```
|
- spec: `docs/superpowers/specs/2026-05-23-video-studio-node-canvas-design.md` (untracked — 커밋 필요)
|
||||||
|
- 목적: 무신사·우리카드 AI 영상 공모전 실전 제작 도구
|
||||||
영향 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`
|
### music 파이프라인 stuck 감지
|
||||||
```python
|
- 6/12 신뢰성 작업이 명시적으로 남긴 갭: `*_running` hang · `*_pending` 방치 · retrying 중 컨테이너 재시작 시 stuck(현 retry 가드가 state=failed라 재retry 불가)
|
||||||
# 변경 전 — stock 08:00과 5분 차이로 겹침
|
- 상세: `memory/service_music.md` "파이프라인 신뢰성/복구 — 범위 밖"
|
||||||
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
|
- **Redis 큐 통합 모니터링** — agent-office에 `queue:*-render`/`queue:paused` 길이·상태 패널 (NAS↔Windows 작업 흐름 가시화)
|
||||||
- 매번 launch X → 1개 인스턴스 재사용
|
- **weight-evolver 성과 대시보드** — auto_picks 적중 추이 + weight_base 진화 그래프 (자율 학습 실효성 검증)
|
||||||
- 카드 10장 렌더 시간 30% 단축 기대
|
- **lotto-signals 패턴 확장** — adaptive baseline + z-score + urgent 텔레그램을 stock(이상치)·realestate(경쟁률 급변)에 재사용
|
||||||
- [x] `card_renderer.py` 내부에 모듈 레벨 `_PLAYWRIGHT`/`_BROWSER` + `init_browser`/`shutdown_browser` 함수 (별도 모듈 분리 안 함, 같은 파일에 인접 배치)
|
- **nginx internal 차단 표준화** — insta/music/video/image 3-layer 차단을 공통 include로 추출
|
||||||
- [x] `_render_slate_locked` 본체에서 `_get_browser()` 재사용 (crashed 시 lazy 재초기화)
|
- **agent-office 레거시 정리** — tarot_readings 테이블 잔존(tarot-lab 분리 후), seed "blog" 죽은 에이전트
|
||||||
- [x] `main.py` startup hook에서 `init_browser()`, shutdown hook에서 `shutdown_browser()`
|
|
||||||
|
|
||||||
### 7. stock 뉴스 스크랩 비동기화 — ⚠️ 보류 2026-05-18
|
### 보류 유지 (박재오 판단 대기)
|
||||||
- **재진단**: stock은 `BackgroundScheduler` 사용 중 → main loop 블로킹 없음 (이미 별도 thread)
|
- stock 뉴스 스크랩 비동기화 — BackgroundScheduler I/O wait라 CPU 미미, 큰 리팩토링 vs 효과 불명확
|
||||||
- `fetch_market_news`의 4개 동기 `requests.get`은 network I/O wait라 CPU 거의 사용 안 함
|
- lotto Monte Carlo 빈도(6→3회/일) — CPU 50%↓ vs 자율 학습 정확도 trade-off
|
||||||
- `to_thread`로 wrap해도 BackgroundScheduler 환경에서 사실상 의미 없음
|
- 컨테이너 리소스 제한 — ❌ 박재오 금지(J4025 2C throughput 손해) · NAS 업그레이드 ⏸️ 보류(Redis 분산으로 우선순위↓)
|
||||||
- 진짜 효과를 보려면 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)
|
## 🔧 진단 커맨드 (NAS bash)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 실시간 CPU 사용 (상위 15)
|
top -b -n 1 | head -25 # CPU 상위
|
||||||
top -b -n 1 | head -25
|
docker stats --no-stream # 컨테이너별 CPU/메모리
|
||||||
|
docker exec redis redis-cli PING # Redis 헬스
|
||||||
# 프로세스별 CPU 정렬
|
docker exec redis redis-cli KEYS 'queue:*' # 큐 키 목록
|
||||||
ps aux --sort=-%cpu | head -15
|
docker exec redis redis-cli LLEN queue:insta-render # 큐 길이
|
||||||
|
|
||||||
# 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
|
docker logs agent-office 2>&1 | grep -E "APScheduler|executing" | tail -50
|
||||||
|
docker exec insta-lab ps aux | grep chromium | wc -l # (분할 후 0이어야 정상)
|
||||||
# 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`
|
- 메모리 인덱스: `memory/MEMORY.md` (14 서비스 × `service_<name>.md` authoritative)
|
||||||
- 위키 페이지: [[사업-개인-웹-플랫폼]] (CPU 부하 진단 섹션 + 컨테이너 표)
|
- Windows 워커 짝: web-ai 레포 (insta/music/video/image-render)
|
||||||
- docker-compose.yml: 본 디렉토리 루트
|
- spec/plan: `docs/superpowers/specs|plans/`
|
||||||
|
- docker-compose.yml: 루트
|
||||||
|
|
||||||
## 변경 이력
|
## 변경 이력
|
||||||
|
|
||||||
- 2026-05-18: 페이지 신설. 즉시 5건 + 중기 4건 + 장기 3건. 진단 커맨드.
|
- 2026-05-18: 페이지 신설. CPU 진단 즉시 5건 + 7결정 분산 가이드.
|
||||||
|
- 2026-05-22: 분산·자율화 구현 반영. Redis 분할·lotto 능동시그널·weight-evolver.
|
||||||
|
- 2026-06-12: **5/25~6/12 전체 작업 반영** — tarot/saju 분리·신설, _shared 로그, lotto v3 백테스트, stock 보유종목 인텔, nginx CVE, insta 카드뉴스 v2 + 자율발급, 에이전트 오버사이트, music 파이프라인 신뢰성. 미완성 큰 기능(Video Studio 프론트) + 후속(music stuck 감지) + 백로그 재편. 현재 트랙(oversight 프론트 배포) 명시.
|
||||||
|
|||||||
49
CLAUDE.md
49
CLAUDE.md
@@ -21,7 +21,7 @@
|
|||||||
## 1. 프로젝트 개요
|
## 1. 프로젝트 개요
|
||||||
|
|
||||||
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||||
- **서비스 14개**: lotto, stock, music-lab, video-lab, image-lab, insta-lab, realestate-lab, agent-office, tarot-lab, saju-lab, personal, packs-lab, travel-proxy, deployer
|
- **서비스 15개**: lotto, stock, music-lab, video-lab, image-lab, insta-lab, realestate-lab, agent-office, tarot-lab, saju-lab, personal, packs-lab, travel-proxy, co-gahusb, deployer
|
||||||
- **공유 인프라**: `_shared/access_log` 모듈 (5개 서비스 공유), `redis` (music/video/image/insta-lab 큐 공유)
|
- **공유 인프라**: `_shared/access_log` 모듈 (5개 서비스 공유), `redis` (music/video/image/insta-lab 큐 공유)
|
||||||
- **렌더/생성 위임**: music/video/image/insta의 무거운 생성·렌더는 **Windows AI 워커**(`web-ai` 별도 레포)가 담당. NAS 서비스는 Redis 큐 push + 결과 webhook 수신만 한다.
|
- **렌더/생성 위임**: music/video/image/insta의 무거운 생성·렌더는 **Windows AI 워커**(`web-ai` 별도 레포)가 담당. NAS 서비스는 Redis 큐 push + 결과 webhook 수신만 한다.
|
||||||
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
|
||||||
@@ -80,7 +80,8 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||||
| `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
|
| `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
|
||||||
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
||||||
| `redis` | 6379 | 비동기 큐 (music/video/image/insta-lab 공유) |
|
| `co-gahusb` | 18920 | 세션 간 협업 팀 버스 (FastMCP streamable-http + Redis, Bearer `CO_BUS_KEY`, DNS-rebinding 보호 off) |
|
||||||
|
| `redis` | 6379 | 비동기 큐 (music/video/image/insta-lab + co-gahusb 공유) |
|
||||||
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
||||||
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
||||||
|
|
||||||
@@ -106,12 +107,14 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
|||||||
| `/api/blog/` | `personal:8000` | 블로그 API |
|
| `/api/blog/` | `personal:8000` | 블로그 API |
|
||||||
| `/api/profile/` | `personal:8000` | 포트폴리오 API |
|
| `/api/profile/` | `personal:8000` | 포트폴리오 API |
|
||||||
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket (86400s) |
|
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket (86400s) |
|
||||||
|
| `/api/co/` | `co-gahusb:8000/` | MCP 팀 버스 (trailing-slash strip → `/mcp`, `Authorization` forward, `proxy_buffering off`, 3600s) |
|
||||||
| `/api/packs/upload` | `packs-lab:8000` | 5GB multipart 업로드 (`client_max_body_size 5G`, `proxy_request_buffering off`, **1800s** timeout) |
|
| `/api/packs/upload` | `packs-lab:8000` | 5GB multipart 업로드 (`client_max_body_size 5G`, `proxy_request_buffering off`, **1800s** timeout) |
|
||||||
| `/api/packs/` | `packs-lab:8000` | 다운로드/list |
|
| `/api/packs/` | `packs-lab:8000` | 다운로드/list |
|
||||||
| `/api/internal/insta/` | `insta-lab:8000` | Windows 워커 webhook (nginx IP 화이트리스트 + 앱 `X-Internal-Key`) |
|
| `/api/internal/insta/` | `insta-lab:8000` | Windows 워커 webhook (nginx IP 화이트리스트 + 앱 `X-Internal-Key`) |
|
||||||
| `/api/internal/music/` | `music-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
|
| `/api/internal/music/` | `music-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
|
||||||
| `/api/internal/video/` | `video-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
|
| `/api/internal/video/` | `video-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
|
||||||
| `/api/internal/image/` | `image-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
|
| `/api/internal/image/` | `image-lab:8000` | Windows 워커 webhook (IP 화이트리스트 + `X-Internal-Key`) |
|
||||||
|
| `/api/internal/realestate/` | `realestate-lab:8000` | naver-fetch 워커 targets 조회 + 매물 ingest (IP 화이트리스트 + `X-Internal-Key`) |
|
||||||
| `/api/webai/` | `stock:8000` | Windows AI 서버 프록시 (rate-limited 60r/m) |
|
| `/api/webai/` | `stock:8000` | Windows AI 서버 프록시 (rate-limited 60r/m) |
|
||||||
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
|
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
|
||||||
| `/ext/feargreed` | CNN API | 공포탐욕지수 외부 프록시 |
|
| `/ext/feargreed` | CNN API | 공포탐욕지수 외부 프록시 |
|
||||||
@@ -244,6 +247,10 @@ docker compose up -d
|
|||||||
| GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 (`days`) |
|
| GET | `/api/portfolio/snapshot/history` | 스냅샷 이력 (`days`) |
|
||||||
| GET/POST | `/api/portfolio/sell-history` | 매도 내역 조회/저장 |
|
| GET/POST | `/api/portfolio/sell-history` | 매도 내역 조회/저장 |
|
||||||
| PUT/DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 수정/삭제 |
|
| PUT/DELETE | `/api/portfolio/sell-history/{id}` | 매도 기록 수정/삭제 |
|
||||||
|
| GET/POST/DELETE | `/api/stock/watchlist` (+ `/{ticker}`) | 실시간 매수 알람 관심종목 CRUD |
|
||||||
|
| GET | `/api/stock/trade-alerts` | 매매 알람 이력 (`days`) |
|
||||||
|
| GET | `/api/webai/trade-alert/monitor-set` | (워커) 감시대상 조립 = watchlist∪screener∪보유 + session/params (X-WebAI-Key) |
|
||||||
|
| POST | `/api/webai/trade-alert/report` | (워커) 발화집합 수신 → edge diff → 신규만 텔레그램 push (X-WebAI-Key) |
|
||||||
|
|
||||||
### music-lab (music-lab/)
|
### music-lab (music-lab/)
|
||||||
듀얼 프로바이더 음악 생성(Suno + MusicGen) + YouTube 영상 자동화 파이프라인 + 시장 트렌드.
|
듀얼 프로바이더 음악 생성(Suno + MusicGen) + YouTube 영상 자동화 파이프라인 + 시장 트렌드.
|
||||||
@@ -266,6 +273,7 @@ docker compose up -d
|
|||||||
| POST/GET | `/api/music/compile` (+ `/compiles/{id}/export`) | 컴파일 |
|
| POST/GET | `/api/music/compile` (+ `/compiles/{id}/export`) | 컴파일 |
|
||||||
| POST/GET/DELETE | `/api/music/video-project` (+ `/{id}/render`, `/export`) | 영상 프로젝트 |
|
| POST/GET/DELETE | `/api/music/video-project` (+ `/{id}/render`, `/export`) | 영상 프로젝트 |
|
||||||
| ALL | `/api/music/pipeline` (생성/start/feedback/cancel/publish/retry/telegram-msg/lookup) | YouTube 자동화 파이프라인. `POST /{id}/retry`=실패 step 재개(publish+업로드완료 시 409) |
|
| ALL | `/api/music/pipeline` (생성/start/feedback/cancel/publish/retry/telegram-msg/lookup) | YouTube 자동화 파이프라인. `POST /{id}/retry`=실패 step 재개(publish+업로드완료 시 409) |
|
||||||
|
| DELETE | `/api/music/pipeline/{id}` | 파이프라인 행 하드 삭제(자식 jobs/feedback 포함, 전체 목록에서 제거). 없으면 404 |
|
||||||
| GET/PUT | `/api/music/setup` | 파이프라인 설정 |
|
| GET/PUT | `/api/music/setup` | 파이프라인 설정 |
|
||||||
| GET | `/api/music/youtube/auth-url`, `/callback`, `/status`; POST `/disconnect` | YouTube OAuth |
|
| GET | `/api/music/youtube/auth-url`, `/callback`, `/status`; POST `/disconnect` | YouTube OAuth |
|
||||||
| GET/POST/PUT/DELETE | `/api/music/revenue` (+ `/dashboard`) | 수익 기록 |
|
| GET/POST/PUT/DELETE | `/api/music/revenue` (+ `/dashboard`) | 수익 기록 |
|
||||||
@@ -325,10 +333,13 @@ docker compose up -d
|
|||||||
| POST | `/api/internal/insta/update` | Windows 워커 결과 webhook |
|
| POST | `/api/internal/insta/update` | Windows 워커 결과 webhook |
|
||||||
|
|
||||||
### realestate-lab (realestate-lab/)
|
### realestate-lab (realestate-lab/)
|
||||||
공공데이터포털 청약 분양정보 수집 + 자치구 5티어 매칭 + agent-office push 알림.
|
공공데이터포털 청약 분양정보 수집 + 자치구 5티어 매칭 + agent-office push 알림. **+ 매물 알림(전월세·매매) + 안전마진/매수 적정성 판정 + 재무·규제 예산 산정**(2026-07-09 추가).
|
||||||
- 핵심 파일: `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py`
|
- 핵심 파일(청약): `main.py`, `db.py`, `collector.py`, `matcher.py`, `notifier.py`, `models.py`
|
||||||
|
- 핵심 파일(매물): `listing_collector.py`(국토부 실거래[합법, **collect은 MOLIT 전용**]), `listing_matcher.py`(안전마진·적정성), `finance_rules.py`+`finance_rules_config.py`(전세대출·LTV·DSR·토허), `lawd_codes.py`(5자리 시군구+10자리 법정동 `NAVER_CORTAR`), `internal_router.py`+`auth.py`(naver-fetch 워커 계약), `pipeline_lock.py`(매칭+알림 직렬화)
|
||||||
- 매칭 100점: 지역35 / 주택유형10 / 면적15 / 가격15 / 자격25
|
- 매칭 100점: 지역35 / 주택유형10 / 면적15 / 가격15 / 자격25
|
||||||
- 📌 상세(DB 스키마·스케줄러 4단계·매칭 모델·notifier 멱등 흐름·env): **`service_realestate.md`**
|
- 매물 판정: 임차 전세가율 ≤0.70🟢/≤0.80🟡/>0.80🔴, 매매 호가율 ≤0.97🟢/≤1.05🟡/>1.05🔴, 실거래 표본<3 ⚪보류
|
||||||
|
- ⚠️ **네이버 호가는 NAS가 직접 안 긁음**(datacenter IP 차단) → **Windows `naver-fetch` 워커**(가정 IP, web-ai)가 `GET /api/internal/realestate/targets`로 대상 받고 네이버 fetch → `POST /listings-ingest`로 push. NAS `collect_listings`는 MOLIT 실거래 baseline만. 관측: node_monitor `fetcher` kind → `/infra`
|
||||||
|
- 📌 상세(DB 스키마·스케줄러·매칭 모델·notifier 멱등·매물 4테이블·판정 경계·env): **`service_realestate.md`**
|
||||||
|
|
||||||
| 메서드 | 경로 | 설명 |
|
| 메서드 | 경로 | 설명 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
@@ -342,10 +353,19 @@ docker compose up -d
|
|||||||
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
|
| POST | `/api/realestate/matches/refresh` | 매칭 재계산 |
|
||||||
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 |
|
| PATCH | `/api/realestate/matches/{id}/read` | 신규 알림 읽음 |
|
||||||
| GET | `/api/realestate/dashboard` | 요약 (진행중·신규매칭·일정) |
|
| GET | `/api/realestate/dashboard` | 요약 (진행중·신규매칭·일정) |
|
||||||
|
| GET | `/api/realestate/listings` | 매물 목록 (dong/deal_type/tier/matched_only/page/size) |
|
||||||
|
| POST/GET | `/api/realestate/listings/collect` (+ `/collect/status`) | 매물 수동 수집(collect→match→notify)/상태 |
|
||||||
|
| GET/PUT | `/api/realestate/listings/criteria` | 매물 조건(동·거래유형·보증금상한·자기자금·연소득 등) 조회/수정 |
|
||||||
|
| GET | `/api/realestate/listings/matches` | 매물 매칭+판정 결과 |
|
||||||
|
| POST | `/api/realestate/listings/rematch` | MOLIT 재수집 없이 기존 매물 즉시 재판정 (~2초, criteria 변경 후) → `{total,passed,judged}` |
|
||||||
|
| POST | `/api/realestate/safety-check` | 단건 안전마진/적정성 판정 (실거래 median 대비) |
|
||||||
|
| POST | `/api/realestate/budget` | 전세/매수 예산·규제 산정 (전세대출·LTV·DSR·토허) |
|
||||||
|
| GET | `/api/internal/realestate/targets` | (naver-fetch 워커) 대상 동+10자리 cortarNo+거래유형 (X-Internal-Key) |
|
||||||
|
| POST | `/api/internal/realestate/listings-ingest` | (naver-fetch 워커) 네이버 raw 매물 push→파싱·upsert·매칭·알림 (X-Internal-Key) |
|
||||||
|
|
||||||
### agent-office (agent-office/)
|
### agent-office (agent-office/)
|
||||||
AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출, 실시간 WebSocket + 텔레그램 봇.
|
AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출, 실시간 WebSocket + 텔레그램 봇.
|
||||||
- 핵심 파일: `main.py`, `db.py`, `config.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/`(stock/music/realestate/youtube/youtube_publisher/lotto/base)
|
- 핵심 파일: `main.py`, `db.py`, `config.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `node_monitor.py`(분산 워커 관측 집계+경보), `agents/`(stock/music/realestate/youtube/youtube_publisher/lotto/base)
|
||||||
- 에이전트 7종 레지스트리. 명령 API body 필드명 → `reference_agent_office_command_api.md`
|
- 에이전트 7종 레지스트리. 명령 API body 필드명 → `reference_agent_office_command_api.md`
|
||||||
- 📌 상세(DB 9테이블·FSM·전체 cron 목록·AGENT_CONTAINER_MAP·텔레그램 캐싱·env): **`service_agent_office.md`**
|
- 📌 상세(DB 9테이블·FSM·전체 cron 목록·AGENT_CONTAINER_MAP·텔레그램 캐싱·env): **`service_agent_office.md`**
|
||||||
|
|
||||||
@@ -358,13 +378,16 @@ AI 에이전트 가상 오피스 — 기존 서비스 API를 프록시로 호출
|
|||||||
| POST | `/api/agent-office/command` | 에이전트 명령 전송 |
|
| POST | `/api/agent-office/command` | 에이전트 명령 전송 |
|
||||||
| POST | `/api/agent-office/approve` | 작업 승인/거부 |
|
| POST | `/api/agent-office/approve` | 작업 승인/거부 |
|
||||||
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook (realestate_bookmark_* 콜백 포함) |
|
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook (realestate_bookmark_* 콜백 포함) |
|
||||||
| POST | `/api/agent-office/realestate/notify` | realestate-lab 전용 push 수신 → 텔레그램 |
|
| POST | `/api/agent-office/realestate/notify` | realestate-lab 청약 매칭 push 수신 → 텔레그램 |
|
||||||
|
| POST | `/api/agent-office/realestate/notify-listing` | realestate-lab 매물 알림 push 수신 → 텔레그램(너+아내, 안전마진/적정성 렌더) |
|
||||||
| GET | `/api/agent-office/states` | 전체 에이전트 상태 |
|
| GET | `/api/agent-office/states` | 전체 에이전트 상태 |
|
||||||
|
| GET | `/api/agent-office/nodes` | 분산 워커(NAS↔Windows) 관측 — heartbeat 생사+큐깊이+dead-letter 집계 (web-ui `/infra` Three.js 시각화 소비). 상세 → `infra_distributed_workers.md` |
|
||||||
| GET | `/api/agent-office/activity` | 전 에이전트 통합 활동 피드 (tasks+logs UNION). 필터 `agent_id`/`type`(task\|log)/`status`/`days` + `limit`/`offset` |
|
| GET | `/api/agent-office/activity` | 전 에이전트 통합 활동 피드 (tasks+logs UNION). 필터 `agent_id`/`type`(task\|log)/`status`/`days` + `limit`/`offset` |
|
||||||
| GET | `/api/agent-office/conversation/stats` | 텔레그램 대화 토큰·캐시 통계 (`days`) |
|
| GET | `/api/agent-office/conversation/stats` | 텔레그램 대화 토큰·캐시 통계 (`days`) |
|
||||||
| POST/GET | `/api/agent-office/youtube/research` (+ `/status`) | YouTube 트렌드 수집 트리거/상태 |
|
| POST/GET | `/api/agent-office/youtube/research` (+ `/status`) | YouTube 트렌드 수집 트리거/상태 |
|
||||||
| GET | `/api/agent-office/lotto/signals`, `/lotto/baselines` | 로또 시그널 이력·baseline |
|
| GET | `/api/agent-office/lotto/signals`, `/lotto/baselines` | 로또 시그널 이력·baseline |
|
||||||
| POST | `/api/agent-office/lotto/signal-check` | 로또 시그널 평가 트리거 (light/sim/deep) |
|
| POST | `/api/agent-office/lotto/signal-check` | 로또 시그널 평가 트리거 (light/sim/deep) |
|
||||||
|
| POST | `/api/agent-office/stock/trade-alert` | stock에서 push된 매매 알람 → 텔레그램(너+아내). 봇 명령 `/watch`·`/unwatch`·`/watchlist`로 watchlist 관리 |
|
||||||
|
|
||||||
### tarot-lab (tarot-lab/)
|
### tarot-lab (tarot-lab/)
|
||||||
타로 카드 해석 (Claude Sonnet, agent-office에서 2026-05-25 독립).
|
타로 카드 해석 (Claude Sonnet, agent-office에서 2026-05-25 독립).
|
||||||
@@ -483,5 +506,17 @@ Gitea Webhook 수신 → 자동 배포. HMAC SHA256 검증(`X-Gitea-Signature`
|
|||||||
- **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준)
|
- **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준)
|
||||||
- **Windows AI 서버 IP**: `192.168.45.59` (DHCP 고정 예약). Tailscale은 Synology userspace 모드라 TCP 불가 → 로컬 IP 사용
|
- **Windows AI 서버 IP**: `192.168.45.59` (DHCP 고정 예약). Tailscale은 Synology userspace 모드라 TCP 불가 → 로컬 IP 사용
|
||||||
- **렌더/생성 워커 분리**: music/video/image/insta 무거운 작업은 Windows `web-ai` 워커. NAS 코드의 `*_provider.py`/`card_renderer.py`가 DEPRECATED stub면 실 로직은 web-ai 쪽이 authoritative
|
- **렌더/생성 워커 분리**: music/video/image/insta 무거운 작업은 Windows `web-ai` 워커. NAS 코드의 `*_provider.py`/`card_renderer.py`가 DEPRECATED stub면 실 로직은 web-ai 쪽이 authoritative
|
||||||
|
- **[팀 규칙] 모든 WSL(docker) 워커는 `/infra`에서 관측 가능해야 한다**: 새 워커 추가 시 필수 3단계 — ① 워커가 `worker:<name>:heartbeat`(EX45, ~15초) 발신 ② BE가 `agent-office/app/node_monitor.py`의 `WORKER_REGISTRY`에 `{name,kind,queue}` 등재 ③ → `/api/agent-office/nodes`·web-ui `/infra` 노출 + 다운/복구/dead-letter 텔레그램 경보. 미준수 = "사일런트 사망"(insta-render 2주 무관측 사고) 재발 위험. 워커 신규/변경 PR 머지 게이트. web-ai/web-ui repo CLAUDE.md에도 동일 규칙 명시 필요. 상세는 `infra_distributed_workers.md` 메모리(관측 계약 2)
|
||||||
- **Playwright Dockerfile**: bookworm 고정 + 수동 chromium deps, `--with-deps` 금지 (`feedback_playwright_dockerfile.md`)
|
- **Playwright Dockerfile**: bookworm 고정 + 수동 chromium deps, `--with-deps` 금지 (`feedback_playwright_dockerfile.md`)
|
||||||
- **lab 네이밍**: `-lab`은 개발/연구 단계에만, 정식 서비스엔 미사용 (`feedback_lab_naming.md`)
|
- **lab 네이밍**: `-lab`은 개발/연구 단계에만, 정식 서비스엔 미사용 (`feedback_lab_naming.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 협업 팀 버스 (co-gahusb) — 이 세션의 역할: **BE**
|
||||||
|
|
||||||
|
이 세션은 백엔드(BE) 역할이다. co-gahusb MCP 툴로 다른 세션(FE/AI/Producer)과 협업한다.
|
||||||
|
- **소유권**: 이 세션은 `web-backend` repo만 쓴다(FE=web-ui, AI=web-ai).
|
||||||
|
- **공유 리소스 변경 전 반드시 `acquire_lock(resource, "BE")`**: 대상 = `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`, `nginx-conf`, `compose`. 점유 중이면 대기, 긴 작업은 `heartbeat_lock`, 끝나면 `release_lock`.
|
||||||
|
- **모든 툴 호출에 `role="BE"`** (또는 `from_role`/`created_by`에 BE).
|
||||||
|
- **수신**: `/loop`로 주기적으로 `read_inbox("BE", after_id=<last>)` + `list_tasks(assignee_role="BE")` 확인.
|
||||||
|
- 키 `CO_BUS_KEY`는 환경변수로 주입(커밋 금지).
|
||||||
|
|||||||
54
README.md
54
README.md
@@ -115,6 +115,7 @@ curl http://localhost:18500/health
|
|||||||
- **실계좌**: Windows AI 서버(192.168.45.59:8000) 프록시 → KIS Open API (잔고/주문)
|
- **실계좌**: Windows AI 서버(192.168.45.59:8000) 프록시 → KIS Open API (잔고/주문)
|
||||||
- **포트폴리오**: 종목·예수금·매도 히스토리 관리, 현재가 자동 조회
|
- **포트폴리오**: 종목·예수금·매도 히스토리 관리, 현재가 자동 조회
|
||||||
- **자산 스냅샷**: 평일 15:40 자동 저장 (KRX 공휴일 판별, `holidays.json` 매년 갱신)
|
- **자산 스냅샷**: 평일 15:40 자동 저장 (KRX 공휴일 판별, `holidays.json` 매년 갱신)
|
||||||
|
- **실시간 매매 알람** (2026-07-02): 장중(+시간외) 1분 폴링으로 매수(watchlist ∪ 스크리너 후보, TA 시그널)·매도(보유종목, exit 룰 + 트레일링 스톱) 조건 충족 시 텔레그램(본인+아내) 알람. **TA 계산은 Windows `trade-monitor` WSL2 docker 워커**, NAS는 감시대상 조립 + edge 중복판정(영속) + 발송 담당. 관심종목은 `/api/stock/watchlist` CRUD 또는 텔레그램 `/watch` 봇 명령. webai 계약: `GET /api/webai/trade-alert/monitor-set` · `POST /report`. 워커/프론트 탭은 web-ai/web-ui repo (설계: `docs/superpowers/specs/2026-07-02-realtime-trade-alerts-design.md`)
|
||||||
|
|
||||||
**LLM provider 전환** — `LLM_PROVIDER` 환경변수
|
**LLM provider 전환** — `LLM_PROVIDER` 환경변수
|
||||||
- `claude` (기본): Anthropic Messages API (`claude-haiku-4-5`)
|
- `claude` (기본): Anthropic Messages API (`claude-haiku-4-5`)
|
||||||
@@ -169,6 +170,8 @@ AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에
|
|||||||
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||||
- 봇이 작업 결과를 텔레그램으로 푸시, 명령은 텔레그램에서 바로 에이전트에 전달
|
- 봇이 작업 결과를 텔레그램으로 푸시, 명령은 텔레그램에서 바로 에이전트에 전달
|
||||||
- Webhook 검증 후 `chat.id` 기준 라우팅
|
- Webhook 검증 후 `chat.id` 기준 라우팅
|
||||||
|
- **실시간 매매 알람 수신**: `POST /api/agent-office/stock/trade-alert` (stock이 edge 판정한 알람 push) → 텔레그램 본인+아내 발송. 봇 명령 `/watch`·`/unwatch`·`/watchlist`로 관심종목 관리
|
||||||
|
- **분산 워커 관측**: `GET /api/agent-office/nodes`가 `worker:<name>:heartbeat`를 집계 → web-ui `/infra` 시각화 + 다운/복구/dead-letter 텔레그램 경보. WSL docker 워커는 `node_monitor.WORKER_REGISTRY` 등재 필수(위 주의사항 팀 규칙)
|
||||||
|
|
||||||
#### 에이전트 구성
|
#### 에이전트 구성
|
||||||
|
|
||||||
@@ -283,11 +286,11 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
|||||||
| DB | 소유 서비스 | 주요 테이블 |
|
| DB | 소유 서비스 | 주요 테이블 |
|
||||||
|----|------------|-----------|
|
|----|------------|-----------|
|
||||||
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
|
| `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 |
|
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history, holdings_signals, news_sentiment, **watchlist, trade_alert_state, trade_alert_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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages, youtube_research_jobs, lotto_signals/baselines, notified_failed_pipelines (파이프라인 실패 알림 dedup) |
|
||||||
| `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts |
|
| `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts |
|
||||||
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
| `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 |
|
| `pack_files` (외부 Supabase) | packs-lab | filename, host_path, mime, byte_size, sha256, deleted_at |
|
||||||
@@ -384,6 +387,52 @@ PORTFOLIO_EDIT_PASSWORD=
|
|||||||
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
|
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
|
||||||
- **LLM provider 롤백** — Claude API 장애 시 `.env`의 `LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`
|
- **LLM provider 롤백** — Claude API 장애 시 `.env`의 `LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`
|
||||||
- **시뮬레이션 교체 방식** — `best_picks`는 교체형 (`is_active=0` 비활성화 후 신규 입력)
|
- **시뮬레이션 교체 방식** — `best_picks`는 교체형 (`is_active=0` 비활성화 후 신규 입력)
|
||||||
|
- **[팀 규칙] 모든 WSL docker 워커는 `/infra` 관측 필수** — 새 워커는 ① `worker:<name>:heartbeat`(EX45) 발신 ② BE가 `agent-office/app/node_monitor.py`의 `WORKER_REGISTRY`에 등재 ③ → `/api/agent-office/nodes`·web-ui `/infra` 노출 + 다운/복구/dead-letter 경보. 미준수 = 사일런트 사망 재발(insta-render 2주 사고). 워커 PR 머지 게이트
|
||||||
|
- **Alpine + tzdata 함정** — stock 컨테이너는 `python:3.12-alpine` + tzdata 미설치라 `TZ=Asia/Seoul`이 무효 → `date.today()`가 UTC. KST 날짜는 `_today_kst()`(=`utcnow()+9h`) 명시 변환 필수 (아침 스케줄 리포트 하루 밀림 방지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 하네스 엔지니어링 (Claude Code 제어)
|
||||||
|
|
||||||
|
이 레포는 Claude Code 세션의 동작을 `.claude/` 설정으로 **제어(harness engineering)** 한다. 모든 산출물은 git 추적되어 이 체크아웃의 모든 세션(co-gahusb 팀버스의 BE 역할 포함)에 공유된다.
|
||||||
|
|
||||||
|
### 제어 표면 (무엇을 통제하는가)
|
||||||
|
|
||||||
|
| 레이어 | 메커니즘 | 위치 | 역할 |
|
||||||
|
|--------|---------|------|------|
|
||||||
|
| 컨텍스트 주입 | CLAUDE.md 계층 + 서비스 메모리 | `CLAUDE.md`, `memory/service_*.md` | 항상 로딩되는 카탈로그(불변) ↔ 관련 시 recall(가변) 2계층 |
|
||||||
|
| 권한 가드 | permissions allow/deny/ask | `.claude/settings.json` | 읽기전용 명령 무프롬프트 / 시크릿·DB 차단 / push·reset 확인 |
|
||||||
|
| 행동 강제 | PreToolUse·PostToolUse·SessionStart hook | `.claude/hooks/` | CLAUDE.md 주석 규칙을 하네스가 실제 차단·환기 |
|
||||||
|
| 반복 워크플로우 | slash commands | `.claude/commands/` | `/co-inbox`, `/svc`, `/harness-audit` |
|
||||||
|
| 전문 역할 | subagents | `.claude/agents/` | `be-developer`, `evaluator` |
|
||||||
|
| 협업 버스 | MCP 서버 | `.mcp.json` | co-gahusb 팀버스(세션 간 메시지·작업·락) |
|
||||||
|
|
||||||
|
### 적용된 가드 (hook)
|
||||||
|
|
||||||
|
| hook | 이벤트 / matcher | 동작 | 근거 |
|
||||||
|
|------|-----------------|------|------|
|
||||||
|
| `pretooluse-guard.sh` | PreToolUse · `Bash\|PowerShell` | **차단** 로컬 docker 변경(`up/down/build/restart/exec…`; ps·logs·config·images는 허용) | `feedback_docker_nas` |
|
||||||
|
| 〃 | 〃 | **차단** `git commit --amend` · `git push --force`(`--force-with-lease`는 허용) | `feedback_concurrent_session_git_collision` |
|
||||||
|
| 〃 | 〃 | **차단** PowerShell `>`/`>>` 파일 리다이렉트(UTF-16 BOM; `2>$null`·`> $null`은 허용) | `feedback_powershell_redirect_encoding` |
|
||||||
|
| `posttooluse-memory.sh` | PostToolUse · `Edit\|Write` | 서비스 `db.py`/`models.py`/스케줄러/`.sql` 편집 시 `service_<name>.md` 갱신 환기(비차단) | 메모리 디스플린 |
|
||||||
|
| `session-start.sh` | SessionStart · `startup\|resume` | BE 역할 + 수신함/락 넛지 주입 | 협업 버스 프로토콜 |
|
||||||
|
|
||||||
|
차단 판단 로직은 `.claude/hooks/_guard.py`(Python). 래퍼는 파서 부재 시 **fail-open**(통과)하고, 출력은 UTF-8로 고정한다.
|
||||||
|
|
||||||
|
### slash commands
|
||||||
|
|
||||||
|
| 커맨드 | 용도 |
|
||||||
|
|--------|------|
|
||||||
|
| `/co-inbox` | co-gahusb 팀버스 BE 수신함(inbox + tasks + locks) 일괄 확인 |
|
||||||
|
| `/svc <name>` | 해당 `service_<name>.md` 메모리 + 핵심 파일 위치를 즉시 로드 |
|
||||||
|
| `/harness-audit` | 서브에이전트 fan-out으로 CLAUDE.md 카탈로그 ↔ 실제 코드 드리프트 감사 |
|
||||||
|
|
||||||
|
### 확장 / 유지보수
|
||||||
|
|
||||||
|
- **hook 경로는 이 머신 기준 절대경로**(`/c/Users/jaeoh/Desktop/workspace/web-backend/.claude/hooks/…`)다. 레포를 다른 경로로 클론하면 `settings.json`의 3개 hook command 경로를 갱신해야 한다.
|
||||||
|
- 가드 패턴 추가/수정은 `_guard.py`만 고치면 된다(설정 변경 불필요).
|
||||||
|
- hook은 새 세션에서 자동 로드된다. 진행 중 세션에 즉시 반영하려면 `/hooks` 메뉴를 열거나 재시작한다.
|
||||||
|
- 메모리 디스플린: 코드 구조가 바뀌면 **CLAUDE.md(불변 카탈로그)** 가 아니라 **`service_*.md`(가변 상세)** 를 갱신한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -391,3 +440,4 @@ PORTFOLIO_EDIT_PASSWORD=
|
|||||||
|
|
||||||
- `CLAUDE.md` — Claude Code 작업용 상세 컨텍스트 (API 전체 목록, 테이블 스키마 등)
|
- `CLAUDE.md` — Claude Code 작업용 상세 컨텍스트 (API 전체 목록, 테이블 스키마 등)
|
||||||
- `docs/` — 서비스별 기획·설계 문서
|
- `docs/` — 서비스별 기획·설계 문서
|
||||||
|
- `.claude/` — 하네스 설정(settings·hooks·commands·agents). 위 "하네스 엔지니어링" 섹션 참조
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import logging
|
|||||||
from .base import BaseAgent
|
from .base import BaseAgent
|
||||||
from . import classify_intent
|
from . import classify_intent
|
||||||
from .. import service_proxy
|
from .. import service_proxy
|
||||||
from ..db import add_log
|
from ..db import (
|
||||||
|
add_log,
|
||||||
|
get_notified_failed_pipelines,
|
||||||
|
add_notified_failed_pipeline,
|
||||||
|
prune_notified_failed_pipelines,
|
||||||
|
)
|
||||||
from ..telegram.messaging import send_raw
|
from ..telegram.messaging import send_raw
|
||||||
|
|
||||||
logger = logging.getLogger("agent-office.youtube_publisher")
|
logger = logging.getLogger("agent-office.youtube_publisher")
|
||||||
@@ -25,8 +30,9 @@ class YoutubePublisherAgent(BaseAgent):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
# 진행 중(*_pending) 승인 요청 dedup — 인메모리 유지(의도적).
|
||||||
|
# 재시작 시 살아있는 파이프라인 승인 재알림은 유용한 리마인더라 스팸 아님.
|
||||||
self._notified_state_per_pipeline: dict[int, tuple] = {}
|
self._notified_state_per_pipeline: dict[int, tuple] = {}
|
||||||
self._notified_failed: set[int] = set()
|
|
||||||
|
|
||||||
async def poll_state_changes(self) -> None:
|
async def poll_state_changes(self) -> None:
|
||||||
"""주기적으로 호출되어 *_pending 신규 진입 시 텔레그램 발송."""
|
"""주기적으로 호출되어 *_pending 신규 진입 시 텔레그램 발송."""
|
||||||
@@ -52,18 +58,21 @@ class YoutubePublisherAgent(BaseAgent):
|
|||||||
try:
|
try:
|
||||||
failed = await service_proxy.list_failed_pipelines()
|
failed = await service_proxy.list_failed_pipelines()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# 일시적 폴링 실패를 "failed 없음"으로 오해하면 원장을 비워 재알림 스팸이 남.
|
||||||
|
# → 원장을 건드리지 않고 조용히 종료(다음 폴링에서 재시도).
|
||||||
logger.warning("failed 폴링 실패: %s", e)
|
logger.warning("failed 폴링 실패: %s", e)
|
||||||
failed = []
|
return
|
||||||
|
notified = get_notified_failed_pipelines()
|
||||||
for p in failed:
|
for p in failed:
|
||||||
pid = p.get("id")
|
pid = p.get("id")
|
||||||
if pid is None:
|
if pid is None:
|
||||||
continue
|
continue
|
||||||
if pid not in self._notified_failed:
|
if pid not in notified:
|
||||||
await self._notify_failed(p)
|
await self._notify_failed(p)
|
||||||
self._notified_failed.add(pid)
|
add_notified_failed_pipeline(pid)
|
||||||
# 재개되어 failed에서 벗어난 파이프라인은 재알림 가능하도록 해제
|
# 재개되어 failed에서 벗어난 파이프라인은 재알림 가능하도록 원장에서 제거
|
||||||
failed_ids = {p.get("id") for p in failed}
|
failed_ids = {p.get("id") for p in failed if p.get("id") is not None}
|
||||||
self._notified_failed &= failed_ids
|
prune_notified_failed_pipelines(failed_ids)
|
||||||
|
|
||||||
async def _notify_failed(self, p: dict) -> None:
|
async def _notify_failed(self, p: dict) -> None:
|
||||||
reason = p.get("failed_reason") or "?"
|
reason = p.get("failed_reason") or "?"
|
||||||
|
|||||||
@@ -51,3 +51,9 @@ AGENT_CONTAINER_MAP: dict[str, tuple[str, int, _re.Pattern]] = {
|
|||||||
"insta": ("insta-lab", 8000, _re.compile(r"^/api/insta")),
|
"insta": ("insta-lab", 8000, _re.compile(r"^/api/insta")),
|
||||||
"realestate": ("realestate-lab", 8000, _re.compile(r"^/api/realestate")),
|
"realestate": ("realestate-lab", 8000, _re.compile(r"^/api/realestate")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Redis (node monitor)
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
|
||||||
|
NODE_ALERT_DEADLETTER_THRESHOLD = int(os.getenv("NODE_ALERT_DEADLETTER_THRESHOLD", "1"))
|
||||||
|
# heartbeat TTL(45s)의 2배 — 키가 남아있어도 age>90s면 dead 판정
|
||||||
|
NODE_STALE_THRESHOLD_SEC = int(os.getenv("NODE_STALE_THRESHOLD_SEC", "90"))
|
||||||
|
|||||||
@@ -158,6 +158,12 @@ def init_db() -> None:
|
|||||||
CREATE INDEX IF NOT EXISTS idx_tarot_favorite
|
CREATE INDEX IF NOT EXISTS idx_tarot_favorite
|
||||||
ON tarot_readings(favorite, created_at DESC)
|
ON tarot_readings(favorite, created_at DESC)
|
||||||
""")
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS notified_failed_pipelines (
|
||||||
|
pipeline_id INTEGER PRIMARY KEY,
|
||||||
|
notified_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
# Seed default agent configs
|
# Seed default agent configs
|
||||||
for agent_id, name in [
|
for agent_id, name in [
|
||||||
("stock", "주식 트레이더"),
|
("stock", "주식 트레이더"),
|
||||||
@@ -826,6 +832,47 @@ def get_all_baselines() -> List[Dict[str, Any]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- notified_failed_pipelines (파이프라인 실패 알림 dedup 원장, 재시작 지속) ---
|
||||||
|
|
||||||
|
def get_notified_failed_pipelines() -> set:
|
||||||
|
"""이미 실패 알림을 발송한 pipeline_id 집합."""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT pipeline_id FROM notified_failed_pipelines"
|
||||||
|
).fetchall()
|
||||||
|
return {r["pipeline_id"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def add_notified_failed_pipeline(pipeline_id: int) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO notified_failed_pipelines(pipeline_id) VALUES(?)",
|
||||||
|
(pipeline_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prune_notified_failed_pipelines(active_failed_ids) -> None:
|
||||||
|
"""현재 failed 목록에 없는 pipeline_id를 원장에서 제거.
|
||||||
|
|
||||||
|
재개되어 failed에서 벗어난 파이프라인이 다시 실패하면 재알림 가능하도록 함.
|
||||||
|
(기존 인메모리 `_notified_failed &= failed_ids`의 영속 버전)
|
||||||
|
"""
|
||||||
|
keep = set(active_failed_ids)
|
||||||
|
with _conn() as conn:
|
||||||
|
existing = {
|
||||||
|
r["pipeline_id"]
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT pipeline_id FROM notified_failed_pipelines"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
stale = existing - keep
|
||||||
|
for pid in stale:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM notified_failed_pipelines WHERE pipeline_id=?",
|
||||||
|
(pid,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_tasks_by_agent_date_kind(agent_id: str, date_iso: str, task_type: str) -> List[Dict[str, Any]]:
|
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."""
|
"""같은 (agent, date, task_type)으로 이미 생성된 task 조회. 멱등 guard."""
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
|
|||||||
@@ -187,6 +187,11 @@ async def telegram_webhook(data: dict):
|
|||||||
def all_states():
|
def all_states():
|
||||||
return {"agents": get_all_agent_states()}
|
return {"agents": get_all_agent_states()}
|
||||||
|
|
||||||
|
@app.get("/api/agent-office/nodes")
|
||||||
|
async def nodes_status():
|
||||||
|
from .node_monitor import collect_status
|
||||||
|
return await collect_status()
|
||||||
|
|
||||||
@app.get("/api/agent-office/agents/{agent_id}/token-usage")
|
@app.get("/api/agent-office/agents/{agent_id}/token-usage")
|
||||||
def agent_token_usage(agent_id: str, days: int = 1):
|
def agent_token_usage(agent_id: str, days: int = 1):
|
||||||
from .db import get_token_usage_stats
|
from .db import get_token_usage_stats
|
||||||
@@ -222,6 +227,24 @@ async def realestate_notify(body: RealestateNotifyBody):
|
|||||||
return await agent.on_new_matches(body.matches)
|
return await agent.on_new_matches(body.matches)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Realestate Listing Notify Endpoint (매물 알림, 청약 notify와 별도) ---
|
||||||
|
|
||||||
|
class ListingNotifyBody(BaseModel):
|
||||||
|
listings: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/agent-office/realestate/notify-listing")
|
||||||
|
async def realestate_notify_listing(body: ListingNotifyBody):
|
||||||
|
from .notifiers.telegram_realestate_listing import send_listing_alerts
|
||||||
|
from .db import add_log
|
||||||
|
res = await send_listing_alerts(body.listings)
|
||||||
|
for a in body.listings:
|
||||||
|
add_log("realestate",
|
||||||
|
f"매물알림 {a.get('deal_type')} {a.get('complex_name')} "
|
||||||
|
f"{a.get('safety_tier') or a.get('valuation_tier')}", "info")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
# --- YouTube Research Agent Endpoints ---
|
# --- YouTube Research Agent Endpoints ---
|
||||||
|
|
||||||
class YouTubeResearchBody(BaseModel):
|
class YouTubeResearchBody(BaseModel):
|
||||||
@@ -273,3 +296,19 @@ async def trigger_signal_check(source: str = "light"):
|
|||||||
if not agent:
|
if not agent:
|
||||||
raise HTTPException(status_code=503, detail="lotto agent not registered")
|
raise HTTPException(status_code=503, detail="lotto agent not registered")
|
||||||
return await agent.run_signal_check(source=source)
|
return await agent.run_signal_check(source=source)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Trade Alert Notify Endpoint ---
|
||||||
|
|
||||||
|
class TradeAlertBody(BaseModel):
|
||||||
|
alerts: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/agent-office/stock/trade-alert")
|
||||||
|
async def stock_trade_alert(body: TradeAlertBody):
|
||||||
|
from .notifiers.telegram_trade import send_trade_alerts
|
||||||
|
from .db import add_log
|
||||||
|
res = await send_trade_alerts(body.alerts)
|
||||||
|
for a in body.alerts:
|
||||||
|
add_log("stock", f"매매알람 {a.get('kind')} {a.get('ticker')} {a.get('condition')}", "info")
|
||||||
|
return res
|
||||||
|
|||||||
156
agent-office/app/node_monitor.py
Normal file
156
agent-office/app/node_monitor.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""분산 워커 상태 집계 (read-only). Global Constraints 계약 2 스키마 생성."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import datetime as dt, json, logging
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from .config import REDIS_URL, NODE_ALERT_DEADLETTER_THRESHOLD, NODE_STALE_THRESHOLD_SEC
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-office.node_monitor")
|
||||||
|
|
||||||
|
_node_state: dict[str, bool] = {} # name -> 직전 alive
|
||||||
|
_dl_notified: dict[str, int] = {} # name -> 직전 알린 dead_letter 수
|
||||||
|
|
||||||
|
WORKER_REGISTRY = [
|
||||||
|
{"name": "music-render", "kind": "render", "queue": "queue:music-render"},
|
||||||
|
{"name": "video-render", "kind": "render", "queue": "queue:video-render"},
|
||||||
|
{"name": "image-render", "kind": "render", "queue": "queue:image-render"},
|
||||||
|
{"name": "insta-render", "kind": "render", "queue": "queue:insta-render"},
|
||||||
|
{"name": "task-watcher", "kind": "watcher", "queue": None},
|
||||||
|
{"name": "ai_trade", "kind": "trader", "queue": None},
|
||||||
|
{"name": "trade-monitor", "kind": "trader", "queue": None},
|
||||||
|
{"name": "naver-fetch", "kind": "fetcher", "queue": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
_redis = None
|
||||||
|
def _get_redis():
|
||||||
|
global _redis
|
||||||
|
if _redis is None:
|
||||||
|
_redis = aioredis.from_url(REDIS_URL, decode_responses=False)
|
||||||
|
return _redis
|
||||||
|
|
||||||
|
|
||||||
|
def _beat_age(ts, now):
|
||||||
|
"""ts는 ISO-8601 문자열 또는 epoch 숫자(매물알림 스펙 §4.4) 둘 다 허용."""
|
||||||
|
try:
|
||||||
|
if isinstance(ts, (int, float)):
|
||||||
|
beat = dt.datetime.fromtimestamp(ts, tz=dt.timezone.utc)
|
||||||
|
else:
|
||||||
|
beat = dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||||
|
return max(0, int((now - beat).total_seconds()))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _render_link_status(w):
|
||||||
|
if not w["alive"]:
|
||||||
|
return "down"
|
||||||
|
if w["state"] == "paused":
|
||||||
|
return "paused"
|
||||||
|
if w["dead_letter"] > 0:
|
||||||
|
return "degraded"
|
||||||
|
return "healthy"
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_status(redis=None) -> dict:
|
||||||
|
r = redis or _get_redis()
|
||||||
|
now = dt.datetime.now(dt.timezone.utc)
|
||||||
|
out = {"redis_ok": True, "paused": False, "paused_reason": None,
|
||||||
|
"generated_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"workers": [], "links": []}
|
||||||
|
try:
|
||||||
|
out["paused"] = (await r.get("queue:paused")) == b"1"
|
||||||
|
except Exception:
|
||||||
|
logger.exception("redis 접근 실패")
|
||||||
|
out["redis_ok"] = False
|
||||||
|
return out
|
||||||
|
|
||||||
|
for w in WORKER_REGISTRY:
|
||||||
|
try:
|
||||||
|
info = {"name": w["name"], "kind": w["kind"], "alive": False, "state": None,
|
||||||
|
"last_beat_age_s": None, "queue_depth": 0, "dead_letter": 0,
|
||||||
|
"processing": 0, "jobs_done": 0, "jobs_failed": 0, "last_job_at": None}
|
||||||
|
raw = await r.get(f"worker:{w['name']}:heartbeat")
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
hb = json.loads(raw)
|
||||||
|
age = _beat_age(hb.get("ts") or "", now)
|
||||||
|
info["last_beat_age_s"] = age
|
||||||
|
info["alive"] = age is not None and age <= NODE_STALE_THRESHOLD_SEC
|
||||||
|
info["state"] = hb.get("state")
|
||||||
|
info["jobs_done"] = hb.get("jobs_done", 0)
|
||||||
|
info["jobs_failed"] = hb.get("jobs_failed", 0)
|
||||||
|
info["last_job_at"] = hb.get("last_job_at")
|
||||||
|
if w["kind"] == "watcher" and hb.get("mode"):
|
||||||
|
out["paused_reason"] = hb["mode"]
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
logger.warning("heartbeat JSON 파싱 실패 name=%s", w["name"])
|
||||||
|
if w["queue"]:
|
||||||
|
info["queue_depth"] = await r.llen(w["queue"])
|
||||||
|
info["dead_letter"] = await r.llen(f"dead_letter:{w['queue']}")
|
||||||
|
proc = 0
|
||||||
|
async for key in r.scan_iter(match=f"processing:{w['queue']}:*"):
|
||||||
|
proc += await r.llen(key)
|
||||||
|
info["processing"] = proc
|
||||||
|
out["workers"].append(info)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("워커 상태 수집 실패 name=%s", w["name"])
|
||||||
|
out["redis_ok"] = False
|
||||||
|
break
|
||||||
|
|
||||||
|
for w in out["workers"]:
|
||||||
|
if w["kind"] == "trader":
|
||||||
|
out["links"].append({"from": w["name"], "to": "nas-stock", "type": "http-pull",
|
||||||
|
"status": "healthy" if w["alive"] else "down"})
|
||||||
|
elif w["kind"] == "render":
|
||||||
|
out["links"].append({"from": "nas", "to": w["name"], "type": "redis-queue",
|
||||||
|
"status": _render_link_status(w)})
|
||||||
|
elif w["kind"] == "fetcher":
|
||||||
|
out["links"].append({"from": w["name"], "to": "nas-realestate", "type": "http-pull",
|
||||||
|
"status": "healthy" if w["alive"] else "down"})
|
||||||
|
if out["paused"] and not out["paused_reason"]:
|
||||||
|
out["paused_reason"] = "trading"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def check_and_alert(status=None) -> list[str]:
|
||||||
|
"""워커 상태를 점검해 다운/복구/dead-letter 전이를 텔레그램으로 경보한다.
|
||||||
|
|
||||||
|
첫 관측(prev=None)엔 경보 없음 — 부팅 시 false alarm 방지.
|
||||||
|
반환값: 실제로 전송된 경보 텍스트 목록 (테스트용).
|
||||||
|
"""
|
||||||
|
from .telegram.messaging import send_raw
|
||||||
|
from .db import add_log
|
||||||
|
try:
|
||||||
|
st = status or await collect_status()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("collect_status 예외")
|
||||||
|
return []
|
||||||
|
sent: list[str] = []
|
||||||
|
for w in st["workers"]:
|
||||||
|
name = w["name"]
|
||||||
|
alive = w.get("alive", False)
|
||||||
|
prev = _node_state.get(name)
|
||||||
|
transition_send_failed = False
|
||||||
|
if prev is True and not alive:
|
||||||
|
text = f"🔴 [{name}] 워커 다운"
|
||||||
|
if (await send_raw(text=text)).get("ok"):
|
||||||
|
add_log("node_monitor", f"{name} 다운", "warning"); sent.append(text)
|
||||||
|
else:
|
||||||
|
transition_send_failed = True
|
||||||
|
elif prev is False and alive:
|
||||||
|
text = f"🟢 [{name}] 워커 복구"
|
||||||
|
if (await send_raw(text=text)).get("ok"):
|
||||||
|
add_log("node_monitor", f"{name} 복구", "info"); sent.append(text)
|
||||||
|
else:
|
||||||
|
transition_send_failed = True
|
||||||
|
if not transition_send_failed:
|
||||||
|
_node_state[name] = alive
|
||||||
|
dl = w.get("dead_letter", 0)
|
||||||
|
if dl >= NODE_ALERT_DEADLETTER_THRESHOLD and dl != _dl_notified.get(name, 0):
|
||||||
|
text = f"❌ [{name}] 실패 누적 {dl}건 (dead-letter)"
|
||||||
|
if (await send_raw(text=text)).get("ok"):
|
||||||
|
add_log("node_monitor", f"{name} dead-letter {dl}", "warning")
|
||||||
|
sent.append(text)
|
||||||
|
_dl_notified[name] = dl
|
||||||
|
elif dl == 0:
|
||||||
|
_dl_notified.pop(name, None)
|
||||||
|
return sent
|
||||||
83
agent-office/app/notifiers/telegram_realestate_listing.py
Normal file
83
agent-office/app/notifiers/telegram_realestate_listing.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""매물 알림 텔레그램 포맷+전송 (본인+아내 각각). realestate-lab notify_new_listings 수신.
|
||||||
|
|
||||||
|
telegram_trade.py(매매알람)와 대칭 구조: send_raw 저수준 전송 + chat_id 리스트 순회.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
from ..config import TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-office")
|
||||||
|
|
||||||
|
_TIER_EMOJI = {
|
||||||
|
"안전": "🟢 안전", "주의": "🟡 주의", "위험": "🔴 위험", "보류": "⚪ 보류(표본부족)",
|
||||||
|
"저평가": "🟢 저평가", "시세": "🟡 시세 수준", "고가": "🔴 고가",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _manwon(v) -> str:
|
||||||
|
if not v:
|
||||||
|
return "-"
|
||||||
|
return f"{v / 10000:.1f}억" if v >= 10000 else f"{v:,}만"
|
||||||
|
|
||||||
|
|
||||||
|
def format_listing_alert(a: Dict[str, Any]) -> str:
|
||||||
|
cat = a.get("category") or ("매매" if a.get("deal_type") == "매매" else "임차")
|
||||||
|
if cat == "임차":
|
||||||
|
price = _manwon(a.get("deposit"))
|
||||||
|
tier = _TIER_EMOJI.get(a.get("safety_tier"), a.get("safety_tier") or "")
|
||||||
|
ratio = a.get("jeonse_ratio")
|
||||||
|
if ratio is not None:
|
||||||
|
judge = (f"{tier} (전세가율 {int(ratio * 100)}% · 시세 {_manwon(a.get('market_median'))}, "
|
||||||
|
f"실거래 {a.get('sample_size', 0)}건)")
|
||||||
|
else:
|
||||||
|
judge = f"{tier} (실거래 표본 부족 — 수동 확인)"
|
||||||
|
warn = "⚠️ 등기부 선순위 근저당 수동 확인 필수(인터넷등기소)"
|
||||||
|
else:
|
||||||
|
price = _manwon(a.get("sale_price"))
|
||||||
|
tier = _TIER_EMOJI.get(a.get("valuation_tier"), a.get("valuation_tier") or "")
|
||||||
|
ratio = a.get("price_ratio")
|
||||||
|
budget = " · 예산 내 ✅" if a.get("budget_ok") else ""
|
||||||
|
if ratio is not None:
|
||||||
|
judge = (f"{tier} (호가율 {int(ratio * 100)}% · 실거래 median {_manwon(a.get('market_median'))}, "
|
||||||
|
f"{a.get('sample_size', 0)}건){budget}")
|
||||||
|
else:
|
||||||
|
judge = f"{tier} (실거래 표본 부족 — 수동 확인)"
|
||||||
|
flags = a.get("regulation_flags") or []
|
||||||
|
warn = "⚠️ " + (" · ".join(flags) if flags else "비토허") + " · 등기부 선순위 수동 확인 필수"
|
||||||
|
|
||||||
|
area = f"전용 {a.get('area_exclusive')}㎡" if a.get("area_exclusive") else ""
|
||||||
|
return (
|
||||||
|
f"🏠 [{a.get('deal_type') or ''}] {a.get('complex_name') or ''} · {price} · {area} · {a.get('floor') or ''}\n"
|
||||||
|
f"{judge}\n"
|
||||||
|
f"📍 {a.get('dong') or ''} · {warn}\n"
|
||||||
|
f"🔗 {a.get('url') or ''}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_listing_alerts(listings: List[Dict[str, Any]]) -> dict:
|
||||||
|
"""매물마다 본인+아내 chat_id 각각으로 send_raw. 1건 이상 성공하면 delivered→sent_ids에 id 수집.
|
||||||
|
실패해도 나머지 계속 진행(per-send try/except)."""
|
||||||
|
sent = 0
|
||||||
|
sent_ids: List[Any] = []
|
||||||
|
all_ok = True
|
||||||
|
chat_ids = [c for c in (TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID) if c]
|
||||||
|
for a in listings:
|
||||||
|
text = format_listing_alert(a)
|
||||||
|
delivered = False
|
||||||
|
for cid in chat_ids:
|
||||||
|
try:
|
||||||
|
r = await send_raw(text, chat_id=cid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[telegram_realestate_listing] send failed (chat_id={cid}): {e}")
|
||||||
|
all_ok = False
|
||||||
|
continue
|
||||||
|
if r.get("ok"):
|
||||||
|
sent += 1
|
||||||
|
delivered = True
|
||||||
|
else:
|
||||||
|
all_ok = False
|
||||||
|
if delivered and a.get("id") is not None:
|
||||||
|
sent_ids.append(a["id"])
|
||||||
|
return {"sent": sent, "sent_ids": sent_ids, "ok": all_ok}
|
||||||
61
agent-office/app/notifiers/telegram_trade.py
Normal file
61
agent-office/app/notifiers/telegram_trade.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""매매 알람 텔레그램 포맷+전송 (본인+아내 각각)."""
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from ..telegram.messaging import send_raw
|
||||||
|
from ..config import TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID
|
||||||
|
|
||||||
|
logger = logging.getLogger("agent-office")
|
||||||
|
|
||||||
|
_KIND_LABEL = {"buy": "🟢 매수", "sell": "🔴 매도"}
|
||||||
|
_COND_LABEL = {
|
||||||
|
"buy_ma20_pullback": "지지선 되돌림", "buy_breakout": "돌파", "buy_rsi_bounce": "RSI 과매도 반등",
|
||||||
|
"sell_stop_loss": "손절", "sell_ma_break": "이평 이탈", "sell_take_profit": "익절",
|
||||||
|
"sell_climax": "급등 소진", "sell_trailing_stop": "트레일링 스톱",
|
||||||
|
}
|
||||||
|
# 조건별 "왜 이 시점에 매수/매도인가" 한 줄 근거
|
||||||
|
_COND_REASON = {
|
||||||
|
"buy_ma20_pullback": "상승추세 중 MA20 지지선 눌림목 반등 — 저가 진입 기회",
|
||||||
|
"buy_breakout": "전고점·저항 돌파 + 거래량 증가 — 추세 상승 진입 신호",
|
||||||
|
"buy_rsi_bounce": "RSI 과매도(30↓)에서 반등 — 단기 낙폭과대 되돌림",
|
||||||
|
"sell_stop_loss": "평단 대비 손절선 도달 — 추가 하락 리스크 차단",
|
||||||
|
"sell_ma_break": "주요 이평선(MA50/200) 이탈 — 추세 훼손, 보유 재검토",
|
||||||
|
"sell_take_profit": "목표 수익 도달 — 이익 실현 구간",
|
||||||
|
"sell_climax": "거래량 급증 + 윗꼬리(고점 대비 하락 마감) — 분산·소진 의심",
|
||||||
|
"sell_trailing_stop":"보유기간 고점 대비 하락 — 수익 반납 방어(트레일링 스톱)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_trade_alert(a: Dict[str, Any]) -> str:
|
||||||
|
kind = _KIND_LABEL.get(a["kind"], a["kind"])
|
||||||
|
cond = _COND_LABEL.get(a["condition"], a["condition"])
|
||||||
|
reason = _COND_REASON.get(a["condition"], "")
|
||||||
|
name = a.get("name") or a["ticker"]
|
||||||
|
price = a.get("price")
|
||||||
|
price_s = f"{int(price):,}원" if price else "-"
|
||||||
|
lines = [f"{kind} 알람", f"<b>{name}</b> ({a['ticker']})", f"조건: {cond}"]
|
||||||
|
if reason:
|
||||||
|
lines.append(f"💡 {reason}")
|
||||||
|
lines.append(f"현재가: {price_s}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_trade_alerts(alerts: List[Dict[str, Any]]) -> dict:
|
||||||
|
"""알람마다 본인+아내 chat_id 각각으로 send_raw. 실패해도 계속 진행."""
|
||||||
|
sent = 0
|
||||||
|
all_ok = True
|
||||||
|
chat_ids = [c for c in (TELEGRAM_CHAT_ID, TELEGRAM_WIFE_CHAT_ID) if c]
|
||||||
|
for a in alerts:
|
||||||
|
text = format_trade_alert(a)
|
||||||
|
for cid in chat_ids:
|
||||||
|
try:
|
||||||
|
r = await send_raw(text, chat_id=cid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[telegram_trade] send failed (chat_id={cid}): {e}")
|
||||||
|
all_ok = False
|
||||||
|
continue
|
||||||
|
if r.get("ok"):
|
||||||
|
sent += 1
|
||||||
|
else:
|
||||||
|
all_ok = False
|
||||||
|
return {"sent": sent, "ok": all_ok}
|
||||||
@@ -4,6 +4,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
|
|
||||||
from .agents import AGENT_REGISTRY
|
from .agents import AGENT_REGISTRY
|
||||||
from .db import delete_old_logs
|
from .db import delete_old_logs
|
||||||
|
from . import node_monitor
|
||||||
|
|
||||||
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||||
|
|
||||||
@@ -98,6 +99,9 @@ async def _poll_pipelines():
|
|||||||
if agent:
|
if agent:
|
||||||
await agent.poll_state_changes()
|
await agent.poll_state_changes()
|
||||||
|
|
||||||
|
async def _run_node_health_check():
|
||||||
|
await node_monitor.check_and_alert()
|
||||||
|
|
||||||
def _cleanup_old_logs():
|
def _cleanup_old_logs():
|
||||||
n = delete_old_logs(days=90)
|
n = delete_old_logs(days=90)
|
||||||
if n:
|
if n:
|
||||||
@@ -142,5 +146,6 @@ def init_scheduler():
|
|||||||
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, id="youtube_research")
|
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(_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.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")
|
||||||
|
scheduler.add_job(_run_node_health_check, "interval", seconds=60, id="node_health_check", replace_existing=True)
|
||||||
scheduler.add_job(_cleanup_old_logs, "cron", hour=3, minute=0, id="cleanup_old_logs", replace_existing=True)
|
scheduler.add_job(_cleanup_old_logs, "cron", hour=3, minute=0, id="cleanup_old_logs", replace_existing=True)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|||||||
@@ -111,6 +111,29 @@ async def stock_holdings_brief() -> Dict[str, Any]:
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- stock watchlist (실시간 매매 알람) ---
|
||||||
|
|
||||||
|
async def watchlist_add(ticker: str) -> Dict[str, Any]:
|
||||||
|
"""stock의 관심종목 추가 (POST, 이미 존재하면 멱등하게 갱신)."""
|
||||||
|
resp = await _client.post(f"{STOCK_URL}/api/stock/watchlist", json={"ticker": ticker})
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def watchlist_remove(ticker: str) -> Dict[str, Any]:
|
||||||
|
"""stock의 관심종목 삭제."""
|
||||||
|
resp = await _client.delete(f"{STOCK_URL}/api/stock/watchlist/{ticker}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def watchlist_list() -> Dict[str, Any]:
|
||||||
|
"""stock의 관심종목 목록 조회 → {"watchlist": [...]}."""
|
||||||
|
resp = await _client.get(f"{STOCK_URL}/api/stock/watchlist")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
async def generate_music(payload: dict) -> Dict[str, Any]:
|
async def generate_music(payload: dict) -> Dict[str, Any]:
|
||||||
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
|
resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""텔레그램 Webhook 이벤트 처리."""
|
"""텔레그램 Webhook 이벤트 처리."""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from .. import service_proxy
|
||||||
from ..db import get_telegram_callback, mark_telegram_responded
|
from ..db import get_telegram_callback, mark_telegram_responded
|
||||||
from .client import _enabled, api_call
|
from .client import _enabled, api_call
|
||||||
|
|
||||||
@@ -23,12 +24,43 @@ async def handle_webhook(data: dict, agent_dispatcher=None) -> Optional[dict]:
|
|||||||
if message:
|
if message:
|
||||||
chat = message.get("chat", {})
|
chat = message.get("chat", {})
|
||||||
print(f"[TG-WEBHOOK] chat.id={chat.get('id')} type={chat.get('type')} text={message.get('text')!r}", flush=True)
|
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"):
|
||||||
|
if await handle_watch_command(message):
|
||||||
|
return None
|
||||||
if message and message.get("text") and agent_dispatcher is not None:
|
if message and message.get("text") and agent_dispatcher is not None:
|
||||||
return await _handle_message(message, agent_dispatcher)
|
return await _handle_message(message, agent_dispatcher)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_watch_command(message: dict) -> bool:
|
||||||
|
"""/watch /unwatch /watchlist 명령을 처리해 stock watchlist API로 프록시.
|
||||||
|
|
||||||
|
처리했으면(응답 전송 포함) True, 매칭되지 않는 텍스트면 False."""
|
||||||
|
text = (message.get("text") or "").strip()
|
||||||
|
chat_id = message.get("chat", {}).get("id")
|
||||||
|
parts = text.split()
|
||||||
|
cmd = parts[0].lower() if parts else ""
|
||||||
|
|
||||||
|
if cmd == "/watch" and len(parts) >= 2:
|
||||||
|
await service_proxy.watchlist_add(parts[1])
|
||||||
|
reply = f"관심종목 추가: {parts[1]}"
|
||||||
|
elif cmd == "/unwatch" and len(parts) >= 2:
|
||||||
|
await service_proxy.watchlist_remove(parts[1])
|
||||||
|
reply = f"관심종목 삭제: {parts[1]}"
|
||||||
|
elif cmd == "/watchlist":
|
||||||
|
res = await service_proxy.watchlist_list()
|
||||||
|
items = res.get("watchlist", [])
|
||||||
|
reply = "관심종목:\n" + (
|
||||||
|
"\n".join(f"- {w.get('name') or ''} ({w['ticker']})" for w in items) or "(없음)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await api_call("sendMessage", {"chat_id": chat_id, "text": reply})
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _handle_callback(callback_query: dict) -> Optional[dict]:
|
async def _handle_callback(callback_query: dict) -> Optional[dict]:
|
||||||
"""승인/거절 및 realestate 북마크 콜백 처리."""
|
"""승인/거절 및 realestate 북마크 콜백 처리."""
|
||||||
callback_id = callback_query.get("data", "")
|
callback_id = callback_query.get("data", "")
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ respx>=0.21
|
|||||||
pytest-asyncio>=0.23
|
pytest-asyncio>=0.23
|
||||||
google-api-python-client>=2.100.0
|
google-api-python-client>=2.100.0
|
||||||
pytrends>=4.9.2
|
pytrends>=4.9.2
|
||||||
|
redis>=5.0
|
||||||
|
|||||||
248
agent-office/tests/test_node_monitor.py
Normal file
248
agent-office/tests/test_node_monitor.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# agent-office/tests/test_node_monitor.py
|
||||||
|
import datetime as dt
|
||||||
|
import json, pytest
|
||||||
|
from app import node_monitor
|
||||||
|
import app.node_monitor as nm
|
||||||
|
|
||||||
|
class FakeRedis:
|
||||||
|
"""worker heartbeat + queue llen + scan_iter 흉내."""
|
||||||
|
def __init__(self, kv=None, lists=None):
|
||||||
|
self._kv = kv or {} # key(str) -> bytes
|
||||||
|
self._lists = lists or {} # key(str) -> length(int)
|
||||||
|
async def get(self, key):
|
||||||
|
return self._kv.get(key)
|
||||||
|
async def llen(self, key):
|
||||||
|
return self._lists.get(key, 0)
|
||||||
|
async def scan_iter(self, match=None):
|
||||||
|
prefix = match.rstrip("*")
|
||||||
|
for k in list(self._lists):
|
||||||
|
if k.startswith(prefix):
|
||||||
|
yield k
|
||||||
|
|
||||||
|
def _hb(name, kind, state, ts=None, **extra):
|
||||||
|
"""heartbeat 페이로드 생성. ts 기본값은 현재 시각(신선한 heartbeat)."""
|
||||||
|
if ts is None:
|
||||||
|
ts = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
return json.dumps({"name": name, "kind": kind, "state": state, "ts": ts,
|
||||||
|
"last_job_at": None, "jobs_done": 0, "jobs_failed": 0, **extra}).encode()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_alive_worker_healthy_link():
|
||||||
|
r = FakeRedis(kv={"worker:image-render:heartbeat": _hb("image-render","render","idle")})
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
img = next(w for w in st["workers"] if w["name"] == "image-render")
|
||||||
|
assert img["alive"] is True and img["state"] == "idle"
|
||||||
|
link = next(l for l in st["links"] if l["to"] == "image-render")
|
||||||
|
assert link["status"] == "healthy" and link["type"] == "redis-queue"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_heartbeat_is_dead_and_down():
|
||||||
|
r = FakeRedis() # heartbeat 없음
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
img = next(w for w in st["workers"] if w["name"] == "image-render")
|
||||||
|
assert img["alive"] is False
|
||||||
|
link = next(l for l in st["links"] if l["to"] == "image-render")
|
||||||
|
assert link["status"] == "down"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dead_letter_makes_degraded():
|
||||||
|
r = FakeRedis(kv={"worker:video-render:heartbeat": _hb("video-render","render","idle")},
|
||||||
|
lists={"dead_letter:queue:video-render": 2})
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
vid = next(w for w in st["workers"] if w["name"] == "video-render")
|
||||||
|
assert vid["dead_letter"] == 2
|
||||||
|
link = next(l for l in st["links"] if l["to"] == "video-render")
|
||||||
|
assert link["status"] == "degraded"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_paused_reason_from_watcher():
|
||||||
|
r = FakeRedis(kv={"queue:paused": b"1",
|
||||||
|
"worker:task-watcher:heartbeat": _hb("task-watcher","watcher","trading",mode="trading")})
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
assert st["paused"] is True and st["paused_reason"] == "trading"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trader_http_pull_link():
|
||||||
|
r = FakeRedis(kv={"worker:ai_trade:heartbeat": _hb("ai_trade","trader","market_open")})
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
link = next(l for l in st["links"] if l["from"] == "ai_trade")
|
||||||
|
assert link["type"] == "http-pull" and link["status"] == "healthy"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trade_monitor_registered_and_own_link():
|
||||||
|
"""WSL 워커 trade-monitor가 registry에 있어 /nodes에 노출되고, 링크 from은
|
||||||
|
ai_trade 하드코딩이 아니라 자기 이름(trade-monitor)이어야 한다 (다중 trader 구분)."""
|
||||||
|
r = FakeRedis(kv={"worker:trade-monitor:heartbeat": _hb("trade-monitor", "trader", "market_open")})
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
tm = next(w for w in st["workers"] if w["name"] == "trade-monitor")
|
||||||
|
assert tm["alive"] is True and tm["kind"] == "trader"
|
||||||
|
link = next(l for l in st["links"] if l["from"] == "trade-monitor")
|
||||||
|
assert link["type"] == "http-pull" and link["to"] == "nas-stock" and link["status"] == "healthy"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_paused_no_watcher_heartbeat_fallback_reason():
|
||||||
|
"""paused=True인데 watcher heartbeat 없으면 paused_reason == 'trading' 폴백."""
|
||||||
|
r = FakeRedis(kv={"queue:paused": b"1"}) # watcher heartbeat 없음
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
assert st["paused"] is True
|
||||||
|
assert st["paused_reason"] == "trading"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_processing_count_image_render():
|
||||||
|
"""processing:<queue>:<worker_id> 리스트가 있으면 processing 필드에 합산된다."""
|
||||||
|
worker_id = "abc123"
|
||||||
|
proc_key = f"processing:queue:image-render:{worker_id}"
|
||||||
|
r = FakeRedis(
|
||||||
|
kv={"worker:image-render:heartbeat": _hb("image-render", "render", "busy")},
|
||||||
|
lists={proc_key: 3},
|
||||||
|
)
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
img = next(w for w in st["workers"] if w["name"] == "image-render")
|
||||||
|
assert img["processing"] == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_llen_exception_returns_redis_ok_false():
|
||||||
|
"""워커 루프 중 llen 예외 발생 시 예외를 전파하지 않고 redis_ok=False 반환 (Blocker 회귀)."""
|
||||||
|
class BrokenLlenRedis(FakeRedis):
|
||||||
|
async def llen(self, key):
|
||||||
|
raise ConnectionError("Redis 연결 끊김")
|
||||||
|
|
||||||
|
r = BrokenLlenRedis(
|
||||||
|
kv={"worker:music-render:heartbeat": _hb("music-render", "render", "idle")}
|
||||||
|
)
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
assert st["redis_ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_alert_on_alive_to_dead(monkeypatch):
|
||||||
|
sent = []
|
||||||
|
async def fake_send_raw(text, **kw): sent.append(text); return {"ok": True}
|
||||||
|
monkeypatch.setattr("app.telegram.messaging.send_raw", fake_send_raw)
|
||||||
|
monkeypatch.setattr("app.db.add_log", lambda *a, **k: None)
|
||||||
|
nm._node_state.clear(); nm._dl_notified.clear()
|
||||||
|
alive = {"workers": [{"name":"image-render","alive":True,"dead_letter":0}], "links": []}
|
||||||
|
dead = {"workers": [{"name":"image-render","alive":False,"dead_letter":0}], "links": []}
|
||||||
|
await nm.check_and_alert(status=alive) # 첫 관측 — 경보 없음
|
||||||
|
assert sent == []
|
||||||
|
await nm.check_and_alert(status=dead) # alive→dead 전이
|
||||||
|
assert any("다운" in t for t in sent)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_alert_on_dead_letter_growth(monkeypatch):
|
||||||
|
sent = []
|
||||||
|
async def fake_send_raw(text, **kw): sent.append(text); return {"ok": True}
|
||||||
|
monkeypatch.setattr("app.telegram.messaging.send_raw", fake_send_raw)
|
||||||
|
monkeypatch.setattr("app.db.add_log", lambda *a, **k: None)
|
||||||
|
nm._node_state.clear(); nm._dl_notified.clear()
|
||||||
|
s = {"workers": [{"name":"video-render","alive":True,"dead_letter":2}], "links": []}
|
||||||
|
await nm.check_and_alert(status=s)
|
||||||
|
assert any("dead-letter" in t for t in sent)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dl_notified_not_updated_on_telegram_failure(monkeypatch):
|
||||||
|
"""텔레그램 실패(ok=False) 시 _dl_notified 갱신 안 됨 → 다음 사이클에서 재시도."""
|
||||||
|
calls = []
|
||||||
|
async def fake_send_raw(text, **kw):
|
||||||
|
calls.append(text)
|
||||||
|
if len(calls) == 1:
|
||||||
|
return {"ok": False} # 첫 호출: 텔레그램 다운
|
||||||
|
return {"ok": True} # 두 번째 호출: 성공
|
||||||
|
monkeypatch.setattr("app.telegram.messaging.send_raw", fake_send_raw)
|
||||||
|
monkeypatch.setattr("app.db.add_log", lambda *a, **k: None)
|
||||||
|
nm._node_state.clear(); nm._dl_notified.clear()
|
||||||
|
s = {"workers": [{"name": "video-render", "alive": True, "dead_letter": 2}], "links": []}
|
||||||
|
# 첫 호출: 텔레그램 다운 → ok=False → _dl_notified 갱신 안 됨
|
||||||
|
result1 = await nm.check_and_alert(status=s)
|
||||||
|
assert result1 == []
|
||||||
|
assert nm._dl_notified.get("video-render", 0) == 0
|
||||||
|
# 두 번째 호출: 같은 dl=2 → _dl_notified 미갱신으로 조건 재만족 → 재시도 발송
|
||||||
|
result2 = await nm.check_and_alert(status=s)
|
||||||
|
assert any("dead-letter" in t for t in result2)
|
||||||
|
assert nm._dl_notified.get("video-render") == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── I1: staleness 판정 신규 테스트 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stale_heartbeat_is_dead():
|
||||||
|
"""heartbeat 키가 존재해도 ts가 90s 초과면 alive=False (staleness 판정)."""
|
||||||
|
stale_ts = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(seconds=300)).strftime(
|
||||||
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
)
|
||||||
|
r = FakeRedis(kv={"worker:image-render:heartbeat": _hb("image-render", "render", "idle", ts=stale_ts)})
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
img = next(w for w in st["workers"] if w["name"] == "image-render")
|
||||||
|
assert img["alive"] is False
|
||||||
|
link = next(l for l in st["links"] if l["to"] == "image-render")
|
||||||
|
assert link["status"] == "down"
|
||||||
|
|
||||||
|
|
||||||
|
# ── I2: 전이 발송 실패 시 재시도 회귀 테스트 ──────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transition_send_failure_retries_next_cycle(monkeypatch):
|
||||||
|
"""alive→dead 전이 시 send_raw 실패하면 _node_state 갱신 안 됨 → 다음 사이클 재시도."""
|
||||||
|
calls = []
|
||||||
|
async def fake_send_raw(text, **kw):
|
||||||
|
calls.append(text)
|
||||||
|
if len(calls) == 1:
|
||||||
|
return {"ok": False} # 첫 호출: 텔레그램 다운
|
||||||
|
return {"ok": True} # 두 번째 호출: 성공
|
||||||
|
monkeypatch.setattr("app.telegram.messaging.send_raw", fake_send_raw)
|
||||||
|
monkeypatch.setattr("app.db.add_log", lambda *a, **k: None)
|
||||||
|
nm._node_state.clear(); nm._dl_notified.clear()
|
||||||
|
alive = {"workers": [{"name": "music-render", "alive": True, "dead_letter": 0}], "links": []}
|
||||||
|
dead = {"workers": [{"name": "music-render", "alive": False, "dead_letter": 0}], "links": []}
|
||||||
|
# 첫 관측: baseline 설정(전이 없음)
|
||||||
|
await nm.check_and_alert(status=alive)
|
||||||
|
assert nm._node_state.get("music-render") is True
|
||||||
|
# alive→dead 전이, send_raw 실패 → _node_state 갱신 안 됨
|
||||||
|
result1 = await nm.check_and_alert(status=dead)
|
||||||
|
assert result1 == [] # 경보 미발송
|
||||||
|
assert nm._node_state.get("music-render") is True # 여전히 True
|
||||||
|
# 두 번째 사이클: 동일 dead, send_raw 성공 → 경보 발송
|
||||||
|
result2 = await nm.check_and_alert(status=dead)
|
||||||
|
assert any("다운" in t for t in result2)
|
||||||
|
assert nm._node_state.get("music-render") is False # 이제 갱신
|
||||||
|
|
||||||
|
|
||||||
|
# ── naver-fetch(fetcher) 등재 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_naver_fetch_registered():
|
||||||
|
from app.node_monitor import WORKER_REGISTRY
|
||||||
|
entry = next((w for w in WORKER_REGISTRY if w["name"] == "naver-fetch"), None)
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["kind"] == "fetcher" and entry["queue"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_naver_fetch_http_pull_link():
|
||||||
|
"""naver-fetch heartbeat 존재 시 links에 from=naver-fetch·to=nas-realestate·type=http-pull."""
|
||||||
|
r = FakeRedis(kv={"worker:naver-fetch:heartbeat": _hb("naver-fetch", "fetcher", "idle")})
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
link = next(l for l in st["links"] if l["from"] == "naver-fetch")
|
||||||
|
assert link["type"] == "http-pull" and link["to"] == "nas-realestate" and link["status"] == "healthy"
|
||||||
|
|
||||||
|
|
||||||
|
# ── ts epoch 포맷 지원 (매물알림 스펙 §4.4: naver-fetch는 ts를 epoch 정수로 발신) ──
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_epoch_ts_fresh_is_alive():
|
||||||
|
"""ts가 epoch 정수(스펙 §4.4)여도 신선하면 alive=True."""
|
||||||
|
epoch_now = int(dt.datetime.now(dt.timezone.utc).timestamp())
|
||||||
|
r = FakeRedis(kv={"worker:naver-fetch:heartbeat": _hb("naver-fetch", "fetcher", "idle", ts=epoch_now)})
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
nf = next(w for w in st["workers"] if w["name"] == "naver-fetch")
|
||||||
|
assert nf["alive"] is True and nf["last_beat_age_s"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_epoch_ts_stale_is_dead():
|
||||||
|
"""epoch ts가 90s 초과 과거면 staleness 판정으로 alive=False."""
|
||||||
|
epoch_old = int(dt.datetime.now(dt.timezone.utc).timestamp()) - 300
|
||||||
|
r = FakeRedis(kv={"worker:naver-fetch:heartbeat": _hb("naver-fetch", "fetcher", "idle", ts=epoch_old)})
|
||||||
|
st = await node_monitor.collect_status(redis=r)
|
||||||
|
nf = next(w for w in st["workers"] if w["name"] == "naver-fetch")
|
||||||
|
assert nf["alive"] is False and nf["last_beat_age_s"] >= 300
|
||||||
18
agent-office/tests/test_nodes_endpoint.py
Normal file
18
agent-office/tests/test_nodes_endpoint.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# agent-office/tests/test_nodes_endpoint.py
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
from app import main
|
||||||
|
async def fake_collect(redis=None):
|
||||||
|
return {"redis_ok": True, "paused": False, "paused_reason": None,
|
||||||
|
"generated_at": "2026-06-29T00:00:00Z", "workers": [], "links": []}
|
||||||
|
monkeypatch.setattr("app.node_monitor.collect_status", fake_collect)
|
||||||
|
return TestClient(main.app)
|
||||||
|
|
||||||
|
def test_nodes_endpoint_returns_contract(client):
|
||||||
|
resp = client.get("/api/agent-office/nodes")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert set(["redis_ok","paused","workers","links"]).issubset(body)
|
||||||
78
agent-office/tests/test_realestate_listing_notify.py
Normal file
78
agent-office/tests/test_realestate_listing_notify.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""매물 알림 텔레그램 렌더+전송 테스트. 기존 test_trade_alert_notify.py 패턴(async mock + chat_id patch) 준수."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_format_listing_rent_and_sale():
|
||||||
|
from app.notifiers.telegram_realestate_listing import format_listing_alert
|
||||||
|
|
||||||
|
rent = format_listing_alert({"id": 1, "category": "임차", "deal_type": "전세",
|
||||||
|
"complex_name": "OO", "deposit": 29000, "area_exclusive": 42.0, "floor": "5/15",
|
||||||
|
"safety_tier": "안전", "jeonse_ratio": 0.68, "market_median": 43000, "sample_size": 7,
|
||||||
|
"dong": "신대방동", "url": "http://x", "regulation_flags": []})
|
||||||
|
assert "전세" in rent and "안전" in rent and "OO" in rent and "68" in rent
|
||||||
|
|
||||||
|
sale = format_listing_alert({"id": 2, "category": "매매", "deal_type": "매매",
|
||||||
|
"complex_name": "PP", "sale_price": 89000, "area_exclusive": 59.0, "floor": "12/20",
|
||||||
|
"valuation_tier": "시세", "price_ratio": 1.01, "market_median": 88000, "sample_size": 7,
|
||||||
|
"dong": "상도동", "url": "http://y", "budget_ok": 1, "regulation_flags": []})
|
||||||
|
assert "매매" in sale and "시세" in sale
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_format_listing_pending_sample_and_regulation_flags():
|
||||||
|
"""표본<3(보류)·토허 등 regulation_flags 경고 라인이 포함되는지."""
|
||||||
|
from app.notifiers.telegram_realestate_listing import format_listing_alert
|
||||||
|
|
||||||
|
pending = format_listing_alert({"id": 3, "category": "임차", "deal_type": "전세",
|
||||||
|
"complex_name": "QQ", "deposit": 28000, "area_exclusive": 40.0, "floor": "3/10",
|
||||||
|
"safety_tier": "보류", "jeonse_ratio": None, "market_median": None, "sample_size": 1,
|
||||||
|
"dong": "봉천동", "url": "http://z", "regulation_flags": []})
|
||||||
|
assert "보류" in pending
|
||||||
|
|
||||||
|
toheo = format_listing_alert({"id": 4, "category": "매매", "deal_type": "매매",
|
||||||
|
"complex_name": "RR", "sale_price": 150000, "area_exclusive": 84.0, "floor": "7/15",
|
||||||
|
"valuation_tier": "고가", "price_ratio": 1.1, "market_median": 136000, "sample_size": 5,
|
||||||
|
"dong": "대치동", "url": "http://w", "budget_ok": 0, "regulation_flags": ["토허"]})
|
||||||
|
assert "토허" in toheo
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_listing_alerts_returns_sent_ids():
|
||||||
|
from app.notifiers import telegram_realestate_listing as t
|
||||||
|
items = [{"id": 5, "category": "임차", "deal_type": "전세", "complex_name": "OO",
|
||||||
|
"deposit": 29000, "area_exclusive": 42.0, "safety_tier": "안전",
|
||||||
|
"jeonse_ratio": 0.68, "market_median": 43000, "sample_size": 7,
|
||||||
|
"dong": "신대방동", "url": "http://x", "regulation_flags": []}]
|
||||||
|
with patch("app.notifiers.telegram_realestate_listing.send_raw",
|
||||||
|
new=AsyncMock(return_value={"ok": True})) as m, \
|
||||||
|
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_CHAT_ID", "U"), \
|
||||||
|
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_WIFE_CHAT_ID", "W"):
|
||||||
|
res = await t.send_listing_alerts(items)
|
||||||
|
assert res["sent_ids"] == [5] and res["ok"] is True
|
||||||
|
assert res["sent"] == 2 # 너+아내 둘 다
|
||||||
|
chat_ids = {c.kwargs.get("chat_id") for c in m.await_args_list}
|
||||||
|
assert chat_ids == {"U", "W"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_listing_alerts_partial_failure_not_marked_sent():
|
||||||
|
"""한쪽 chat_id 전송이 예외를 던져도 나머지는 계속 발송되고, 최소 1건 성공이면 sent_ids에 포함."""
|
||||||
|
from app.notifiers import telegram_realestate_listing as t
|
||||||
|
items = [{"id": 6, "category": "매매", "deal_type": "매매", "complex_name": "PP",
|
||||||
|
"sale_price": 89000, "area_exclusive": 59.0, "valuation_tier": "시세",
|
||||||
|
"price_ratio": 1.01, "market_median": 88000, "sample_size": 7,
|
||||||
|
"dong": "상도동", "url": "http://y", "budget_ok": 1, "regulation_flags": []}]
|
||||||
|
|
||||||
|
async def _flaky(text, chat_id=None, **kw):
|
||||||
|
if chat_id == "U":
|
||||||
|
raise RuntimeError("network error")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
with patch("app.notifiers.telegram_realestate_listing.send_raw", side_effect=_flaky), \
|
||||||
|
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_CHAT_ID", "U"), \
|
||||||
|
patch("app.notifiers.telegram_realestate_listing.TELEGRAM_WIFE_CHAT_ID", "W"):
|
||||||
|
res = await t.send_listing_alerts(items)
|
||||||
|
assert res["sent_ids"] == [6]
|
||||||
|
assert res["ok"] is False # 일부 실패했으므로 all_ok=False
|
||||||
67
agent-office/tests/test_trade_alert_notify.py
Normal file
67
agent-office/tests/test_trade_alert_notify.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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(monkeypatch):
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
# config.DB_PATH는 첫 import 시 1회 고정되므로, 다른 테스트 파일과 조합 실행 시
|
||||||
|
# db가 이 파일의 _TMP가 아닌 다른 경로를 쓸 수 있다. db.DB_PATH를 이 파일 전용으로
|
||||||
|
# 강제해 영속 테이블의 테스트 간 누수를 결정적으로 차단.
|
||||||
|
import app.db as _db
|
||||||
|
monkeypatch.setattr(_db, "DB_PATH", _TMP)
|
||||||
|
# WAL 사이드카(-wal/-shm)까지 지워야 영속 상태가 남지 않음
|
||||||
|
for suffix in ("", "-wal", "-shm"):
|
||||||
|
p = _TMP + suffix
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.remove(p)
|
||||||
|
_db.init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_trade_alerts_to_user_and_wife():
|
||||||
|
from app.notifiers import telegram_trade
|
||||||
|
alerts = [{"ticker": "005930", "name": "삼성전자", "kind": "buy",
|
||||||
|
"condition": "buy_breakout", "price": 71500, "detail": {}}]
|
||||||
|
with patch("app.notifiers.telegram_trade.send_raw",
|
||||||
|
new=AsyncMock(return_value={"ok": True})) as m, \
|
||||||
|
patch("app.notifiers.telegram_trade.TELEGRAM_CHAT_ID", "U"), \
|
||||||
|
patch("app.notifiers.telegram_trade.TELEGRAM_WIFE_CHAT_ID", "W"):
|
||||||
|
res = await telegram_trade.send_trade_alerts(alerts)
|
||||||
|
assert res["ok"] is True
|
||||||
|
chat_ids = {c.kwargs.get("chat_id") for c in m.await_args_list}
|
||||||
|
assert chat_ids == {"U", "W"} # 둘 다 발송
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_format_trade_alert_has_direction():
|
||||||
|
from app.notifiers.telegram_trade import format_trade_alert
|
||||||
|
txt = format_trade_alert({"ticker": "005930", "name": "삼성전자", "kind": "sell",
|
||||||
|
"condition": "sell_stop_loss", "price": 60000, "detail": {}})
|
||||||
|
assert "매도" in txt and "삼성전자" in txt
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_trade_alert_includes_reason_line():
|
||||||
|
"""조건별 '왜 매수/매도해야 하는지' 한 줄 이유(💡)가 메시지에 포함된다."""
|
||||||
|
from app.notifiers.telegram_trade import format_trade_alert
|
||||||
|
for cond in ("buy_breakout", "sell_stop_loss", "sell_trailing_stop"):
|
||||||
|
txt = format_trade_alert({"ticker": "005930", "name": "삼성전자", "kind": cond.split("_")[0],
|
||||||
|
"condition": cond, "price": 60000, "detail": {}})
|
||||||
|
assert "💡" in txt, f"{cond}: 이유 한 줄 누락"
|
||||||
|
# 이유 라인이 조건 라벨을 그대로 반복하지 않고 실제 설명을 담아야 함
|
||||||
|
reason_line = next(l for l in txt.split("\n") if l.startswith("💡"))
|
||||||
|
assert len(reason_line) > 6
|
||||||
93
agent-office/tests/test_watch_commands.py
Normal file
93
agent-office/tests/test_watch_commands.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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(monkeypatch):
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
# config.DB_PATH는 첫 import 시 1회 고정되므로, 다른 테스트 파일과 조합 실행 시
|
||||||
|
# db가 이 파일의 _TMP가 아닌 다른 경로를 쓸 수 있다. db.DB_PATH를 이 파일 전용으로
|
||||||
|
# 강제해 영속 테이블의 테스트 간 누수를 결정적으로 차단.
|
||||||
|
import app.db as _db
|
||||||
|
monkeypatch.setattr(_db, "DB_PATH", _TMP)
|
||||||
|
for suffix in ("", "-wal", "-shm"):
|
||||||
|
p = _TMP + suffix
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.remove(p)
|
||||||
|
_db.init_db()
|
||||||
|
yield
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_watch_command_calls_add():
|
||||||
|
from app.telegram import webhook
|
||||||
|
msg = {"chat": {"id": 1}, "text": "/watch 005930"}
|
||||||
|
with patch("app.telegram.webhook.service_proxy.watchlist_add",
|
||||||
|
new=AsyncMock(return_value={"ok": True})) as m, \
|
||||||
|
patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})):
|
||||||
|
handled = await webhook.handle_watch_command(msg)
|
||||||
|
assert handled is True
|
||||||
|
m.assert_awaited_once_with("005930")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_watch_text_ignored():
|
||||||
|
from app.telegram import webhook
|
||||||
|
msg = {"chat": {"id": 1}, "text": "안녕"}
|
||||||
|
assert await webhook.handle_watch_command(msg) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unwatch_command_calls_remove():
|
||||||
|
from app.telegram import webhook
|
||||||
|
msg = {"chat": {"id": 1}, "text": "/unwatch 005930"}
|
||||||
|
with patch("app.telegram.webhook.service_proxy.watchlist_remove",
|
||||||
|
new=AsyncMock(return_value={"ok": True})) as m, \
|
||||||
|
patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})) as sent:
|
||||||
|
handled = await webhook.handle_watch_command(msg)
|
||||||
|
assert handled is True
|
||||||
|
m.assert_awaited_once_with("005930")
|
||||||
|
sent.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_watchlist_command_calls_list_and_formats_items():
|
||||||
|
from app.telegram import webhook
|
||||||
|
msg = {"chat": {"id": 1}, "text": "/watchlist"}
|
||||||
|
items = {"watchlist": [{"ticker": "005930", "name": "삼성전자"}]}
|
||||||
|
with patch("app.telegram.webhook.service_proxy.watchlist_list",
|
||||||
|
new=AsyncMock(return_value=items)) as m, \
|
||||||
|
patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})) as sent:
|
||||||
|
handled = await webhook.handle_watch_command(msg)
|
||||||
|
assert handled is True
|
||||||
|
m.assert_awaited_once_with()
|
||||||
|
text = sent.await_args.args[1]["text"]
|
||||||
|
assert "005930" in text and "삼성전자" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_watch_command_reaches_handle_webhook_before_slash_dispatch():
|
||||||
|
"""handle_webhook이 /watch 를 agent_dispatcher 호출 전에 가로채야 한다."""
|
||||||
|
from app.telegram import webhook
|
||||||
|
data = {"message": {"chat": {"id": 1}, "text": "/watch 005930"}}
|
||||||
|
dispatcher = AsyncMock(side_effect=AssertionError("agent_dispatcher가 호출되면 안 됨"))
|
||||||
|
with patch("app.telegram.webhook.service_proxy.watchlist_add",
|
||||||
|
new=AsyncMock(return_value={"ok": True})) as m, \
|
||||||
|
patch("app.telegram.webhook.api_call", new=AsyncMock(return_value={"ok": True})):
|
||||||
|
result = await webhook.handle_webhook(data, agent_dispatcher=dispatcher)
|
||||||
|
assert result is None
|
||||||
|
m.assert_awaited_once_with("005930")
|
||||||
|
dispatcher.assert_not_awaited()
|
||||||
@@ -14,13 +14,20 @@ from unittest.mock import AsyncMock, patch
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _init_db():
|
def _init_db(monkeypatch):
|
||||||
import gc
|
import gc
|
||||||
gc.collect()
|
gc.collect()
|
||||||
if os.path.exists(_TMP):
|
# config.DB_PATH는 첫 import 시 1회 고정되므로, 다른 테스트 파일과 조합 실행 시
|
||||||
os.remove(_TMP)
|
# db가 이 파일의 _TMP가 아닌 다른 경로를 쓸 수 있다. db.DB_PATH를 이 파일 전용으로
|
||||||
from app.db import init_db
|
# 강제해 영속 테이블(notified_failed_pipelines 등)의 테스트 간 누수를 결정적으로 차단.
|
||||||
init_db()
|
import app.db as _db
|
||||||
|
monkeypatch.setattr(_db, "DB_PATH", _TMP)
|
||||||
|
# WAL 사이드카(-wal/-shm)까지 지워야 영속 상태가 남지 않음
|
||||||
|
for suffix in ("", "-wal", "-shm"):
|
||||||
|
p = _TMP + suffix
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.remove(p)
|
||||||
|
_db.init_db()
|
||||||
yield
|
yield
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
@@ -211,3 +218,70 @@ async def test_failed_poll_exception_is_silent():
|
|||||||
|
|
||||||
# active 알림은 정상 발송
|
# active 알림은 정상 발송
|
||||||
assert sent.await_count == 1
|
assert sent.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_notification_persists_across_restart():
|
||||||
|
"""컨테이너 재시작(새 에이전트 인스턴스)해도 이미 알린 failed는 재알림하지 않음."""
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
failed_pipeline = {
|
||||||
|
"id": 3,
|
||||||
|
"state": "failed",
|
||||||
|
"failed_reason": "video: timeout",
|
||||||
|
"track_title": "beat music v2",
|
||||||
|
}
|
||||||
|
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||||
|
new=AsyncMock(return_value=[]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||||
|
new=AsyncMock(return_value=[failed_pipeline]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=sent,
|
||||||
|
):
|
||||||
|
agent1 = YoutubePublisherAgent()
|
||||||
|
await agent1.poll_state_changes()
|
||||||
|
# 컨테이너 재시작 시뮬레이션: 완전히 새로운 인스턴스(인메모리 상태 소실)
|
||||||
|
agent2 = YoutubePublisherAgent()
|
||||||
|
await agent2.poll_state_changes()
|
||||||
|
|
||||||
|
# 재시작해도 DB 원장으로 중복 방지 → 1회만 알림
|
||||||
|
assert sent.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transient_failed_poll_keeps_ledger():
|
||||||
|
"""failed 폴링이 일시적으로 예외를 던져도 원장을 비우지 않아 다음 폴링에서 재알림하지 않음."""
|
||||||
|
from app.agents.youtube_publisher import YoutubePublisherAgent
|
||||||
|
|
||||||
|
failed_pipeline = {
|
||||||
|
"id": 3,
|
||||||
|
"state": "failed",
|
||||||
|
"failed_reason": "video: timeout",
|
||||||
|
"track_title": "beat music v2",
|
||||||
|
}
|
||||||
|
list_failed = AsyncMock(
|
||||||
|
side_effect=[[failed_pipeline], Exception("boom"), [failed_pipeline]]
|
||||||
|
)
|
||||||
|
sent = AsyncMock(return_value={"ok": True, "message_id": 1})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_active_pipelines",
|
||||||
|
new=AsyncMock(return_value=[]),
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.service_proxy.list_failed_pipelines",
|
||||||
|
new=list_failed,
|
||||||
|
), patch(
|
||||||
|
"app.agents.youtube_publisher.send_raw",
|
||||||
|
new=sent,
|
||||||
|
):
|
||||||
|
agent = YoutubePublisherAgent()
|
||||||
|
await agent.poll_state_changes() # #3 최초 알림
|
||||||
|
await agent.poll_state_changes() # 예외 → 원장 유지되어야 (섣부른 정리 금지)
|
||||||
|
await agent.poll_state_changes() # #3 여전히 failed → 재알림 없어야
|
||||||
|
|
||||||
|
assert sent.await_count == 1
|
||||||
|
|||||||
3
co-gahusb/.gitignore
vendored
Normal file
3
co-gahusb/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
19
co-gahusb/CLIENT_SETUP.md
Normal file
19
co-gahusb/CLIENT_SETUP.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# co-gahusb 클라이언트 설정
|
||||||
|
|
||||||
|
## 공통
|
||||||
|
1. `CO_BUS_KEY` 환경변수를 각 머신에 설정(서버 `.env`의 값과 동일).
|
||||||
|
2. 해당 repo 루트 `.mcp.json`에 co-gahusb HTTP MCP 등록(이 repo의 예시 참고).
|
||||||
|
3. CLAUDE.md 역할 블록의 `/loop` 폴링 규약을 따른다.
|
||||||
|
|
||||||
|
## web-ai (다른 머신)
|
||||||
|
web-ai 머신의 repo 루트에 아래 `.mcp.json` 생성, 역할 = **AI**:
|
||||||
|
```json
|
||||||
|
{ "mcpServers": { "co-gahusb": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" } } } }
|
||||||
|
```
|
||||||
|
web-ai CLAUDE.md에 역할 블록 추가(role="AI", 소유권=web-ai repo, 동일 락 규약).
|
||||||
|
|
||||||
|
## Producer (오케스트레이터 세션)
|
||||||
|
별도 repo 없이 조율 담당. `team_log()`로 전체 활동 감시, `create_task`로 분배, `acquire_lock`로 교차 작업 직렬화.
|
||||||
12
co-gahusb/Dockerfile
Normal file
12
co-gahusb/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.server:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||||
0
co-gahusb/app/__init__.py
Normal file
0
co-gahusb/app/__init__.py
Normal file
21
co-gahusb/app/config.py
Normal file
21
co-gahusb/app/config.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# co-gahusb/app/config.py
|
||||||
|
import os
|
||||||
|
|
||||||
|
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379")
|
||||||
|
CO_BUS_KEY = os.environ.get("CO_BUS_KEY", "")
|
||||||
|
|
||||||
|
# 협업 역할 (세션별 1:1)
|
||||||
|
ROLES = ("FE", "BE", "AI", "Producer")
|
||||||
|
|
||||||
|
# 교차 리소스 어드바이저리 락 대상 (이 외 이름도 락은 가능하나, 규약상 명시 대상)
|
||||||
|
LOCKABLE_RESOURCES = (
|
||||||
|
"nas-deploy",
|
||||||
|
"stock-db-schema",
|
||||||
|
"lotto-db-schema",
|
||||||
|
"memory-mirror",
|
||||||
|
"nginx-conf",
|
||||||
|
"compose",
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_LOCK_TTL = 300
|
||||||
|
TEAM_LOG_MAXLEN = 500
|
||||||
66
co-gahusb/app/locks.py
Normal file
66
co-gahusb/app/locks.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# co-gahusb/app/locks.py
|
||||||
|
from redis.exceptions import WatchError
|
||||||
|
|
||||||
|
LOCK_PREFIX = "co:lock:"
|
||||||
|
|
||||||
|
|
||||||
|
async def acquire_lock(r, resource, role, ttl_sec=300):
|
||||||
|
key = LOCK_PREFIX + resource
|
||||||
|
ok = await r.set(key, role, nx=True, ex=ttl_sec)
|
||||||
|
if ok:
|
||||||
|
return {"acquired": True}
|
||||||
|
held_by = await r.get(key)
|
||||||
|
ttl = await r.ttl(key)
|
||||||
|
return {"acquired": False, "held_by": held_by, "ttl_remaining": max(ttl, 0)}
|
||||||
|
|
||||||
|
|
||||||
|
async def release_lock(r, resource, role):
|
||||||
|
key = LOCK_PREFIX + resource
|
||||||
|
async with r.pipeline() as pipe:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await pipe.watch(key)
|
||||||
|
owner = await pipe.get(key)
|
||||||
|
if owner != role:
|
||||||
|
await pipe.unwatch()
|
||||||
|
return {"released": False, "held_by": owner}
|
||||||
|
pipe.multi()
|
||||||
|
pipe.delete(key)
|
||||||
|
await pipe.execute()
|
||||||
|
return {"released": True}
|
||||||
|
except WatchError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
async def heartbeat_lock(r, resource, role, ttl_sec=300):
|
||||||
|
key = LOCK_PREFIX + resource
|
||||||
|
async with r.pipeline() as pipe:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await pipe.watch(key)
|
||||||
|
owner = await pipe.get(key)
|
||||||
|
if owner != role:
|
||||||
|
await pipe.unwatch()
|
||||||
|
return {"renewed": False, "held_by": owner}
|
||||||
|
pipe.multi()
|
||||||
|
pipe.expire(key, ttl_sec)
|
||||||
|
await pipe.execute()
|
||||||
|
return {"renewed": True}
|
||||||
|
except WatchError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
async def list_locks(r):
|
||||||
|
keys = await r.keys(LOCK_PREFIX + "*")
|
||||||
|
out = []
|
||||||
|
for key in keys:
|
||||||
|
held_by = await r.get(key)
|
||||||
|
if held_by is None:
|
||||||
|
continue
|
||||||
|
ttl = await r.ttl(key)
|
||||||
|
out.append({
|
||||||
|
"resource": key[len(LOCK_PREFIX):],
|
||||||
|
"held_by": held_by,
|
||||||
|
"ttl_remaining": max(ttl, 0),
|
||||||
|
})
|
||||||
|
return {"locks": out}
|
||||||
138
co-gahusb/app/server.py
Normal file
138
co-gahusb/app/server.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# co-gahusb/app/server.py
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.middleware import Middleware
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.routing import Mount, Route
|
||||||
|
|
||||||
|
from app import config, locks, store
|
||||||
|
|
||||||
|
log = logging.getLogger("co-gahusb")
|
||||||
|
_auth_failed_logged = False
|
||||||
|
|
||||||
|
_redis = aioredis.from_url(config.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
# DNS-rebinding 보호 비활성화: 실 보안은 nginx 앞단 Bearer 인증(MCP 도달 전 401)이다.
|
||||||
|
# 원격 HTTPS + 정적키 모델이라 Host 화이트리스트는 보안가치 ~0이고, 도메인 변경 시 또 깨진다.
|
||||||
|
mcp = FastMCP(
|
||||||
|
"co-gahusb",
|
||||||
|
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 메시지 ----
|
||||||
|
@mcp.tool()
|
||||||
|
async def post_message(from_role: str, to_role: str, body: str, thread_id: str = "") -> dict:
|
||||||
|
"""다른 역할의 우편함에 메시지를 보낸다."""
|
||||||
|
res = await store.post_message(_redis, from_role, to_role, body, thread_id or None)
|
||||||
|
await store.log_event(_redis, "message", f"{from_role}→{to_role}: {body[:60]}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def read_inbox(role: str, after_id: int = 0, mark_read: bool = False) -> dict:
|
||||||
|
"""내 역할 우편함을 커서 기반으로 읽는다."""
|
||||||
|
return await store.read_inbox(_redis, role, after_id, mark_read)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 작업 ----
|
||||||
|
@mcp.tool()
|
||||||
|
async def create_task(title: str, assignee_role: str, created_by: str, detail: str = "") -> dict:
|
||||||
|
"""작업을 만들어 특정 역할에 배정한다."""
|
||||||
|
res = await store.create_task(_redis, title, assignee_role, created_by, detail or None)
|
||||||
|
await store.log_event(_redis, "task", f"{created_by} created '{title}' → {assignee_role}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def claim_task(task_id: int, role: str) -> dict:
|
||||||
|
"""open 작업을 점유(in_progress)한다. 이미 점유면 거부."""
|
||||||
|
res = await store.claim_task(_redis, task_id, role)
|
||||||
|
if res.get("ok"):
|
||||||
|
await store.log_event(_redis, "task", f"{role} claimed task#{task_id}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def update_task(task_id: int, status: str, role: str, note: str = "") -> dict:
|
||||||
|
"""작업 상태를 갱신한다 (open/in_progress/blocked/done)."""
|
||||||
|
res = await store.update_task(_redis, task_id, status, role, note or None)
|
||||||
|
await store.log_event(_redis, "task", f"{role} set task#{task_id} → {status}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_tasks(status: str = "", assignee_role: str = "") -> dict:
|
||||||
|
"""작업 목록을 조회한다(상태/담당 필터)."""
|
||||||
|
return await store.list_tasks(_redis, status or None, assignee_role or None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 락 ----
|
||||||
|
@mcp.tool()
|
||||||
|
async def acquire_lock(resource: str, role: str, ttl_sec: int = config.DEFAULT_LOCK_TTL) -> dict:
|
||||||
|
"""공유 리소스 변경 전 어드바이저리 락을 획득한다. 점유 중이면 acquired=false."""
|
||||||
|
res = await locks.acquire_lock(_redis, resource, role, ttl_sec)
|
||||||
|
if res.get("acquired"):
|
||||||
|
await store.log_event(_redis, "lock", f"{role} acquired {resource}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def release_lock(resource: str, role: str) -> dict:
|
||||||
|
"""소유한 락을 해제한다."""
|
||||||
|
res = await locks.release_lock(_redis, resource, role)
|
||||||
|
if res.get("released"):
|
||||||
|
await store.log_event(_redis, "lock", f"{role} released {resource}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def heartbeat_lock(resource: str, role: str, ttl_sec: int = config.DEFAULT_LOCK_TTL) -> dict:
|
||||||
|
"""긴 작업 중 락 TTL을 갱신한다(소유자만)."""
|
||||||
|
return await locks.heartbeat_lock(_redis, resource, role, ttl_sec)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_locks() -> dict:
|
||||||
|
"""현재 점유 중인 모든 락을 조회한다."""
|
||||||
|
return await locks.list_locks(_redis)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 가시성 ----
|
||||||
|
@mcp.tool()
|
||||||
|
async def team_log(after_id: int = 0) -> dict:
|
||||||
|
"""팀 전체 최근 활동 피드(메시지·작업·락)를 조회한다."""
|
||||||
|
return await store.read_team_log(_redis, after_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Bearer 인증 미들웨어 ----
|
||||||
|
class BearerAuth(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request, call_next):
|
||||||
|
global _auth_failed_logged
|
||||||
|
if request.url.path.startswith("/health"):
|
||||||
|
return await call_next(request)
|
||||||
|
expected = f"Bearer {config.CO_BUS_KEY}"
|
||||||
|
if not config.CO_BUS_KEY or request.headers.get("authorization") != expected:
|
||||||
|
if not _auth_failed_logged:
|
||||||
|
log.error("co-gahusb 인증 실패 (이후 동일 로그 생략)")
|
||||||
|
_auth_failed_logged = True
|
||||||
|
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
async def _health(request):
|
||||||
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
_mcp_app = mcp.streamable_http_app()
|
||||||
|
|
||||||
|
app = Starlette(
|
||||||
|
routes=[Route("/health", _health), Mount("/", app=_mcp_app)],
|
||||||
|
middleware=[Middleware(BearerAuth)],
|
||||||
|
lifespan=_mcp_app.router.lifespan_context,
|
||||||
|
)
|
||||||
157
co-gahusb/app/store.py
Normal file
157
co-gahusb/app/store.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# co-gahusb/app/store.py
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.config import TEAM_LOG_MAXLEN
|
||||||
|
|
||||||
|
MSG_SEQ = "co:msgseq"
|
||||||
|
INBOX_PREFIX = "co:inbox:" # list of message ids per role
|
||||||
|
MSG_PREFIX = "co:msg:" # hash per message
|
||||||
|
READ_PREFIX = "co:read:" # last-read cursor per role
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso():
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
|
|
||||||
|
async def post_message(r, from_role, to_role, body, thread_id=None):
|
||||||
|
mid = await r.incr(MSG_SEQ)
|
||||||
|
payload = {
|
||||||
|
"id": str(mid),
|
||||||
|
"from_role": from_role,
|
||||||
|
"to_role": to_role,
|
||||||
|
"body": body,
|
||||||
|
"thread_id": thread_id or "",
|
||||||
|
"ts": _now_iso(),
|
||||||
|
}
|
||||||
|
await r.set(MSG_PREFIX + str(mid), json.dumps(payload))
|
||||||
|
await r.rpush(INBOX_PREFIX + to_role, mid)
|
||||||
|
return {"message_id": mid}
|
||||||
|
|
||||||
|
|
||||||
|
async def read_inbox(r, role, after_id=0, mark_read=False):
|
||||||
|
ids = await r.lrange(INBOX_PREFIX + role, 0, -1)
|
||||||
|
ids = [int(x) for x in ids if int(x) > int(after_id)]
|
||||||
|
messages = []
|
||||||
|
for mid in ids:
|
||||||
|
raw = await r.get(MSG_PREFIX + str(mid))
|
||||||
|
if raw:
|
||||||
|
d = json.loads(raw)
|
||||||
|
d["id"] = int(d["id"])
|
||||||
|
messages.append(d)
|
||||||
|
cursor = ids[-1] if ids else int(after_id)
|
||||||
|
if mark_read and ids:
|
||||||
|
await r.set(READ_PREFIX + role, cursor)
|
||||||
|
return {"messages": messages, "cursor": cursor}
|
||||||
|
|
||||||
|
|
||||||
|
TASK_SEQ = "co:taskseq"
|
||||||
|
TASK_PREFIX = "co:task:" # hash per task
|
||||||
|
TASK_SET = "co:tasks" # set of task ids
|
||||||
|
|
||||||
|
VALID_STATUS = ("open", "in_progress", "blocked", "done")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_task(r, title, assignee_role, created_by, detail=None):
|
||||||
|
tid = await r.incr(TASK_SEQ)
|
||||||
|
task = {
|
||||||
|
"id": str(tid),
|
||||||
|
"title": title,
|
||||||
|
"assignee_role": assignee_role,
|
||||||
|
"status": "open",
|
||||||
|
"detail": detail or "",
|
||||||
|
"created_by": created_by,
|
||||||
|
"note": "",
|
||||||
|
"ts": _now_iso(),
|
||||||
|
}
|
||||||
|
await r.hset(TASK_PREFIX + str(tid), mapping=task)
|
||||||
|
await r.sadd(TASK_SET, tid)
|
||||||
|
return {"task_id": tid}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_task(r, task_id):
|
||||||
|
d = await r.hgetall(TASK_PREFIX + str(task_id))
|
||||||
|
if not d:
|
||||||
|
return None
|
||||||
|
d["id"] = int(d["id"])
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def claim_task(r, task_id, role):
|
||||||
|
key = TASK_PREFIX + str(task_id)
|
||||||
|
async with r.pipeline() as pipe:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await pipe.watch(key)
|
||||||
|
status = await pipe.hget(key, "status")
|
||||||
|
if status is None:
|
||||||
|
await pipe.unwatch()
|
||||||
|
return {"ok": False, "error": "not_found"}
|
||||||
|
if status != "open":
|
||||||
|
held = await pipe.hget(key, "assignee_role")
|
||||||
|
await pipe.unwatch()
|
||||||
|
return {"ok": False, "held_by": held}
|
||||||
|
pipe.multi()
|
||||||
|
pipe.hset(key, mapping={"status": "in_progress", "assignee_role": role})
|
||||||
|
await pipe.execute()
|
||||||
|
return {"ok": True, "task": await _get_task(r, task_id)}
|
||||||
|
except Exception as e:
|
||||||
|
from redis.exceptions import WatchError
|
||||||
|
if isinstance(e, WatchError):
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def update_task(r, task_id, status, role, note=None):
|
||||||
|
if status not in VALID_STATUS:
|
||||||
|
raise ValueError(f"invalid status: {status}")
|
||||||
|
key = TASK_PREFIX + str(task_id)
|
||||||
|
if not await r.exists(key):
|
||||||
|
return {"ok": False, "error": "not_found"}
|
||||||
|
mapping = {"status": status}
|
||||||
|
if note is not None:
|
||||||
|
mapping["note"] = note
|
||||||
|
await r.hset(key, mapping=mapping)
|
||||||
|
return {"ok": True, "task": await _get_task(r, task_id)}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_tasks(r, status=None, assignee_role=None):
|
||||||
|
ids = sorted(int(x) for x in await r.smembers(TASK_SET))
|
||||||
|
tasks = []
|
||||||
|
for tid in ids:
|
||||||
|
t = await _get_task(r, tid)
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
if status and t["status"] != status:
|
||||||
|
continue
|
||||||
|
if assignee_role and t["assignee_role"] != assignee_role:
|
||||||
|
continue
|
||||||
|
tasks.append(t)
|
||||||
|
return {"tasks": tasks}
|
||||||
|
|
||||||
|
|
||||||
|
LOG_SEQ = "co:logseq"
|
||||||
|
LOG_LIST = "co:log" # list of event ids (capped)
|
||||||
|
LOG_PREFIX = "co:logitem:"
|
||||||
|
|
||||||
|
|
||||||
|
async def log_event(r, kind, text):
|
||||||
|
eid = await r.incr(LOG_SEQ)
|
||||||
|
item = {"id": eid, "kind": kind, "text": text, "ts": _now_iso()}
|
||||||
|
await r.set(LOG_PREFIX + str(eid), json.dumps(item))
|
||||||
|
await r.rpush(LOG_LIST, eid)
|
||||||
|
await r.ltrim(LOG_LIST, -TEAM_LOG_MAXLEN, -1)
|
||||||
|
return {"event_id": eid}
|
||||||
|
|
||||||
|
|
||||||
|
async def read_team_log(r, after_id=0, limit=100):
|
||||||
|
ids = [int(x) for x in await r.lrange(LOG_LIST, 0, -1)]
|
||||||
|
ids = [i for i in ids if i > int(after_id)]
|
||||||
|
ids = ids[-limit:]
|
||||||
|
events = []
|
||||||
|
for eid in ids:
|
||||||
|
raw = await r.get(LOG_PREFIX + str(eid))
|
||||||
|
if raw:
|
||||||
|
events.append(json.loads(raw))
|
||||||
|
cursor = ids[-1] if ids else int(after_id)
|
||||||
|
return {"events": events, "cursor": cursor}
|
||||||
3
co-gahusb/pytest.ini
Normal file
3
co-gahusb/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
7
co-gahusb/requirements.txt
Normal file
7
co-gahusb/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mcp>=1.2.0
|
||||||
|
starlette>=0.37
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
redis>=5.0
|
||||||
|
pytest>=8.0
|
||||||
|
pytest-asyncio>=0.24
|
||||||
|
fakeredis>=2.21
|
||||||
0
co-gahusb/tests/__init__.py
Normal file
0
co-gahusb/tests/__init__.py
Normal file
11
co-gahusb/tests/conftest.py
Normal file
11
co-gahusb/tests/conftest.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# co-gahusb/tests/conftest.py
|
||||||
|
import pytest_asyncio
|
||||||
|
import fakeredis.aioredis
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def r():
|
||||||
|
client = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||||
|
await client.flushall()
|
||||||
|
yield client
|
||||||
|
await client.aclose()
|
||||||
51
co-gahusb/tests/test_locks.py
Normal file
51
co-gahusb/tests/test_locks.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# co-gahusb/tests/test_locks.py
|
||||||
|
from app import locks
|
||||||
|
|
||||||
|
|
||||||
|
async def test_acquire_succeeds_then_blocks_other(r):
|
||||||
|
res = await locks.acquire_lock(r, "nas-deploy", "BE", ttl_sec=300)
|
||||||
|
assert res["acquired"] is True
|
||||||
|
|
||||||
|
res2 = await locks.acquire_lock(r, "nas-deploy", "FE", ttl_sec=300)
|
||||||
|
assert res2["acquired"] is False
|
||||||
|
assert res2["held_by"] == "BE"
|
||||||
|
assert res2["ttl_remaining"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_release_only_by_owner(r):
|
||||||
|
await locks.acquire_lock(r, "compose", "BE", ttl_sec=300)
|
||||||
|
|
||||||
|
bad = await locks.release_lock(r, "compose", "FE")
|
||||||
|
assert bad["released"] is False
|
||||||
|
|
||||||
|
ok = await locks.release_lock(r, "compose", "BE")
|
||||||
|
assert ok["released"] is True
|
||||||
|
|
||||||
|
again = await locks.acquire_lock(r, "compose", "FE", ttl_sec=300)
|
||||||
|
assert again["acquired"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heartbeat_only_by_owner_renews_ttl(r):
|
||||||
|
await locks.acquire_lock(r, "nginx-conf", "BE", ttl_sec=10)
|
||||||
|
|
||||||
|
bad = await locks.heartbeat_lock(r, "nginx-conf", "FE", ttl_sec=300)
|
||||||
|
assert bad["renewed"] is False
|
||||||
|
|
||||||
|
ok = await locks.heartbeat_lock(r, "nginx-conf", "BE", ttl_sec=300)
|
||||||
|
assert ok["renewed"] is True
|
||||||
|
assert await r.ttl("co:lock:nginx-conf") > 100
|
||||||
|
|
||||||
|
|
||||||
|
async def test_expired_lock_is_reacquirable(r):
|
||||||
|
await locks.acquire_lock(r, "memory-mirror", "AI", ttl_sec=1)
|
||||||
|
await r.delete("co:lock:memory-mirror")
|
||||||
|
res = await locks.acquire_lock(r, "memory-mirror", "FE", ttl_sec=300)
|
||||||
|
assert res["acquired"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_locks(r):
|
||||||
|
await locks.acquire_lock(r, "nas-deploy", "BE", ttl_sec=300)
|
||||||
|
await locks.acquire_lock(r, "compose", "FE", ttl_sec=300)
|
||||||
|
listed = await locks.list_locks(r)
|
||||||
|
held = {l["resource"]: l["held_by"] for l in listed["locks"]}
|
||||||
|
assert held == {"nas-deploy": "BE", "compose": "FE"}
|
||||||
47
co-gahusb/tests/test_messages.py
Normal file
47
co-gahusb/tests/test_messages.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# co-gahusb/tests/test_messages.py
|
||||||
|
from app import store
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_and_read_ordering(r):
|
||||||
|
id1 = (await store.post_message(r, "Producer", "BE", "first"))["message_id"]
|
||||||
|
id2 = (await store.post_message(r, "Producer", "BE", "second"))["message_id"]
|
||||||
|
assert id2 > id1
|
||||||
|
|
||||||
|
res = await store.read_inbox(r, "BE")
|
||||||
|
bodies = [m["body"] for m in res["messages"]]
|
||||||
|
assert bodies == ["first", "second"]
|
||||||
|
assert res["cursor"] == id2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_inbox_after_id(r):
|
||||||
|
id1 = (await store.post_message(r, "Producer", "BE", "first"))["message_id"]
|
||||||
|
await store.post_message(r, "Producer", "BE", "second")
|
||||||
|
res = await store.read_inbox(r, "BE", after_id=id1)
|
||||||
|
assert [m["body"] for m in res["messages"]] == ["second"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_inboxes_isolated_per_role(r):
|
||||||
|
await store.post_message(r, "Producer", "BE", "for-be")
|
||||||
|
await store.post_message(r, "Producer", "FE", "for-fe")
|
||||||
|
be = await store.read_inbox(r, "BE")
|
||||||
|
fe = await store.read_inbox(r, "FE")
|
||||||
|
assert [m["body"] for m in be["messages"]] == ["for-be"]
|
||||||
|
assert [m["body"] for m in fe["messages"]] == ["for-fe"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mark_read_advances_cursor(r):
|
||||||
|
await store.post_message(r, "Producer", "BE", "first")
|
||||||
|
res = await store.read_inbox(r, "BE", mark_read=True)
|
||||||
|
last = res["cursor"]
|
||||||
|
await store.post_message(r, "Producer", "BE", "second")
|
||||||
|
res2 = await store.read_inbox(r, "BE", after_id=last)
|
||||||
|
assert [m["body"] for m in res2["messages"]] == ["second"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_message_fields(r):
|
||||||
|
await store.post_message(r, "Producer", "BE", "hi", thread_id="t1")
|
||||||
|
res = await store.read_inbox(r, "BE")
|
||||||
|
m = res["messages"][0]
|
||||||
|
assert m["from_role"] == "Producer"
|
||||||
|
assert m["thread_id"] == "t1"
|
||||||
|
assert "ts" in m and "id" in m
|
||||||
54
co-gahusb/tests/test_server.py
Normal file
54
co-gahusb/tests/test_server.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# co-gahusb/tests/test_server.py
|
||||||
|
import os
|
||||||
|
os.environ["CO_BUS_KEY"] = "test-key"
|
||||||
|
|
||||||
|
# config.CO_BUS_KEY는 import 시점에 한 번 읽히므로, 다른 테스트 모듈이 app.config를
|
||||||
|
# 먼저 import하면 빈 값으로 굳는다. import 순서와 무관하게 모듈 속성을 직접 강제한다.
|
||||||
|
from app import config
|
||||||
|
config.CO_BUS_KEY = "test-key"
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from app.server import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_open_without_auth():
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.get("/health")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_requires_bearer():
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.post("/mcp", json={})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_wrong_key_rejected():
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.post("/mcp", json={}, headers={"Authorization": "Bearer wrong"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_valid_auth_passes_dns_host_check():
|
||||||
|
# 유효한 키는 인증 게이트를 통과하고, MCP DNS-rebinding Host 검증에 막혀선 안 된다.
|
||||||
|
# TestClient 기본 Host="testserver"는 localhost가 아니므로, 보호가 켜져 있으면 421.
|
||||||
|
# 컨텍스트 매니저로 써야 lifespan(세션 매니저 task group)이 기동되어 MCP 핸들러까지 도달.
|
||||||
|
with TestClient(app) as client:
|
||||||
|
res = client.post(
|
||||||
|
"/mcp",
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer test-key",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2024-11-05", "capabilities": {},
|
||||||
|
"clientInfo": {"name": "smoke", "version": "0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert res.status_code != 401 # 인증 통과
|
||||||
|
assert res.status_code != 421 # Host 검증에 막히면 안 됨
|
||||||
56
co-gahusb/tests/test_tasks.py
Normal file
56
co-gahusb/tests/test_tasks.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# co-gahusb/tests/test_tasks.py
|
||||||
|
import pytest
|
||||||
|
from app import store
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_and_list(r):
|
||||||
|
res = await store.create_task(r, "deploy FE", "FE", created_by="Producer", detail="ship it")
|
||||||
|
tid = res["task_id"]
|
||||||
|
listed = await store.list_tasks(r)
|
||||||
|
t = [t for t in listed["tasks"] if t["id"] == tid][0]
|
||||||
|
assert t["title"] == "deploy FE"
|
||||||
|
assert t["assignee_role"] == "FE"
|
||||||
|
assert t["status"] == "open"
|
||||||
|
assert t["created_by"] == "Producer"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_claim_then_duplicate_claim_rejected(r):
|
||||||
|
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
|
||||||
|
ok = await store.claim_task(r, tid, "FE")
|
||||||
|
assert ok["ok"] is True
|
||||||
|
assert ok["task"]["status"] == "in_progress"
|
||||||
|
|
||||||
|
dup = await store.claim_task(r, tid, "BE")
|
||||||
|
assert dup["ok"] is False
|
||||||
|
assert dup["held_by"] == "FE"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_status(r):
|
||||||
|
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
|
||||||
|
await store.claim_task(r, tid, "FE")
|
||||||
|
res = await store.update_task(r, tid, "done", "FE", note="finished")
|
||||||
|
assert res["ok"] is True
|
||||||
|
assert res["task"]["status"] == "done"
|
||||||
|
assert res["task"]["note"] == "finished"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_filters(r):
|
||||||
|
t1 = (await store.create_task(r, "a", "FE", created_by="Producer"))["task_id"]
|
||||||
|
await store.create_task(r, "b", "BE", created_by="Producer")
|
||||||
|
await store.claim_task(r, t1, "FE")
|
||||||
|
fe = await store.list_tasks(r, assignee_role="FE")
|
||||||
|
assert [t["title"] for t in fe["tasks"]] == ["a"]
|
||||||
|
in_prog = await store.list_tasks(r, status="in_progress")
|
||||||
|
assert [t["title"] for t in in_prog["tasks"]] == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_status_rejected(r):
|
||||||
|
tid = (await store.create_task(r, "x", "FE", created_by="Producer"))["task_id"]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await store.update_task(r, tid, "bogus", "FE")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_nonexistent_task_returns_not_found(r):
|
||||||
|
res = await store.update_task(r, 999, "done", "FE")
|
||||||
|
assert res["ok"] is False
|
||||||
|
assert res["error"] == "not_found"
|
||||||
25
co-gahusb/tests/test_teamlog.py
Normal file
25
co-gahusb/tests/test_teamlog.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# co-gahusb/tests/test_teamlog.py
|
||||||
|
from app import store
|
||||||
|
|
||||||
|
|
||||||
|
async def test_log_event_and_read(r):
|
||||||
|
await store.log_event(r, "message", "Producer→BE: hi")
|
||||||
|
await store.log_event(r, "lock", "BE acquired nas-deploy")
|
||||||
|
res = await store.read_team_log(r)
|
||||||
|
msgs = [e["text"] for e in res["events"]]
|
||||||
|
assert msgs == ["Producer→BE: hi", "BE acquired nas-deploy"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_team_log_after_id(r):
|
||||||
|
e1 = (await store.log_event(r, "message", "a"))["event_id"]
|
||||||
|
await store.log_event(r, "message", "b")
|
||||||
|
res = await store.read_team_log(r, after_id=e1)
|
||||||
|
assert [e["text"] for e in res["events"]] == ["b"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_team_log_capped(r):
|
||||||
|
for i in range(10):
|
||||||
|
await store.log_event(r, "message", f"m{i}")
|
||||||
|
res = await store.read_team_log(r, limit=3)
|
||||||
|
assert len(res["events"]) == 3
|
||||||
|
assert res["events"][-1]["text"] == "m9"
|
||||||
@@ -206,6 +206,8 @@ services:
|
|||||||
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-}
|
- DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-}
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
|
||||||
- AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000}
|
- AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000}
|
||||||
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||||
|
- NAVER_PAGE_LIMIT=${NAVER_PAGE_LIMIT:-2}
|
||||||
- PYTHONPATH=/app:/shared
|
- PYTHONPATH=/app:/shared
|
||||||
volumes:
|
volumes:
|
||||||
- ${RUNTIME_PATH}/data/realestate:/app/data
|
- ${RUNTIME_PATH}/data/realestate:/app/data
|
||||||
@@ -221,6 +223,25 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
co-gahusb:
|
||||||
|
build:
|
||||||
|
context: ./co-gahusb
|
||||||
|
container_name: co-gahusb
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18920:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
|
- CO_BUS_KEY=${CO_BUS_KEY:-}
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
agent-office:
|
agent-office:
|
||||||
build:
|
build:
|
||||||
context: ./agent-office
|
context: ./agent-office
|
||||||
@@ -249,6 +270,7 @@ services:
|
|||||||
- CONVERSATION_HISTORY_LIMIT=${CONVERSATION_HISTORY_LIMIT:-20}
|
- CONVERSATION_HISTORY_LIMIT=${CONVERSATION_HISTORY_LIMIT:-20}
|
||||||
- CONVERSATION_RATE_PER_MIN=${CONVERSATION_RATE_PER_MIN:-6}
|
- CONVERSATION_RATE_PER_MIN=${CONVERSATION_RATE_PER_MIN:-6}
|
||||||
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
|
- YOUTUBE_DATA_API_KEY=${YOUTUBE_DATA_API_KEY:-}
|
||||||
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
volumes:
|
volumes:
|
||||||
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
|
- ${RUNTIME_PATH:-.}/data/agent-office:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -256,6 +278,7 @@ services:
|
|||||||
- music-lab
|
- music-lab
|
||||||
- insta-lab
|
- insta-lab
|
||||||
- realestate-lab
|
- realestate-lab
|
||||||
|
- redis
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
interval: 60s
|
interval: 60s
|
||||||
@@ -443,7 +466,7 @@ services:
|
|||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- ${RUNTIME_PATH}/redis-data:/data
|
- ${RUNTIME_PATH}/redis-data:/data
|
||||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
command: redis-server --appendonly yes --save "" --stop-writes-on-bgsave-error no --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 60s
|
interval: 60s
|
||||||
|
|||||||
1187
docs/superpowers/plans/2026-06-12-co-gahusb-team-bus.md
Normal file
1187
docs/superpowers/plans/2026-06-12-co-gahusb-team-bus.md
Normal file
File diff suppressed because it is too large
Load Diff
1105
docs/superpowers/plans/2026-06-29-worker-observability.md
Normal file
1105
docs/superpowers/plans/2026-06-29-worker-observability.md
Normal file
File diff suppressed because it is too large
Load Diff
1055
docs/superpowers/plans/2026-07-02-realtime-trade-alerts.md
Normal file
1055
docs/superpowers/plans/2026-07-02-realtime-trade-alerts.md
Normal file
File diff suppressed because it is too large
Load Diff
127
docs/superpowers/specs/2026-06-12-co-gahusb-team-bus-design.md
Normal file
127
docs/superpowers/specs/2026-06-12-co-gahusb-team-bus-design.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# co-gahusb — 세션 간 협업 팀 버스 설계
|
||||||
|
|
||||||
|
작성일: 2026-06-12
|
||||||
|
대상 repo: `web-backend` (서버) + `web-ui`/`web-ai` (클라이언트 배선)
|
||||||
|
목적: 독립 실행되는 4개 Claude Code 세션(FE/BE/AI/Producer)이 역할을 갖고 비동기로 소통·협업하되, 공유 DB/리소스는 동시 쓰기를 방지한다.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
web-ui / web-backend / web-ai 세션은 각각 독립 프로세스라 서로의 컨텍스트를 못 본다. 협업하려면 세 곳(서로 다른 머신 포함)에서 닿는 공유 메시지 버스가 필요하다. 사용자가 방식 B(독립 MCP 서버)를 선택했고, 민감한 공유 영역의 동시 쓰기 분리를 핵심 요구로 명시했다.
|
||||||
|
|
||||||
|
## 결정 사항 (브레인스토밍 확정)
|
||||||
|
|
||||||
|
- 호스팅: 신규 독립 컨테이너 **`co-gahusb`**, NAS, 포트 **18920**(18900 agent-office 옆, 미사용 확인).
|
||||||
|
- 전송/인증: **HTTP streamable MCP** + 정적 **Bearer 키**([[reference_webai_auth_pattern]] 재사용). nginx `/api/co/` → `co-gahusb:18920`, `Authorization` forward.
|
||||||
|
- 백엔드: **Redis**(기존 공유 컨테이너 `redis://redis:6379`). 전 연산 원자적 → SQLite multi-writer 함정([[reference_sqlite_concurrency]]) 회피.
|
||||||
|
- 동시쓰기 분리: **소유권 파티션 + 어드바이저리 락**.
|
||||||
|
- 역할: web-ui=FE, web-backend=BE, web-ai=AI, 이 세션=Producer.
|
||||||
|
- 수신: 각 세션 **/loop 폴링**(`read_inbox` + `list_tasks`).
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
[FE 세션 web-ui] [BE 세션 web-backend] [AI 세션 web-ai(다른 머신)] [Producer 세션]
|
||||||
|
\ | / /
|
||||||
|
\ | / /
|
||||||
|
──────── .mcp.json HTTP + Bearer ───────────────────────────────
|
||||||
|
│
|
||||||
|
nginx /api/co/ (Authorization forward)
|
||||||
|
│
|
||||||
|
co-gahusb:18920 (FastMCP streamable-http)
|
||||||
|
│
|
||||||
|
Redis (원자적 연산)
|
||||||
|
```
|
||||||
|
|
||||||
|
서버 구현: **Python `mcp` SDK(FastMCP) + streamable-http transport**(모든 lab이 FastAPI/Python 스택과 일관). 단일 책임 모듈로 분리:
|
||||||
|
- `app/server.py` — FastMCP 인스턴스 + 툴 등록 + ASGI 앱(streamable-http) + Bearer 인증 미들웨어
|
||||||
|
- `app/store.py` — Redis 데이터 액세스 레이어(메시지/작업/락), 전 함수 원자적
|
||||||
|
- `app/locks.py` — 락 Lua 스크립트(소유자 확인 후 release/heartbeat)
|
||||||
|
- `app/models.py` — 입출력 dataclass/스키마
|
||||||
|
- `app/config.py` — env(REDIS_URL, CO_BUS_KEY, 포트)
|
||||||
|
|
||||||
|
## MCP 툴 표면 (MVP — YAGNI)
|
||||||
|
|
||||||
|
| 분류 | 툴 | 시그니처 → 반환 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 메시지 | `post_message` | `(from_role, to_role, body, thread_id?)` → `{message_id}` |
|
||||||
|
| 메시지 | `read_inbox` | `(role, after_id?, mark_read?=false)` → `{messages:[{id, from_role, body, thread_id, ts}], cursor}` |
|
||||||
|
| 작업 | `create_task` | `(title, assignee_role, detail?, created_by)` → `{task_id}` |
|
||||||
|
| 작업 | `claim_task` | `(task_id, role)` → `{ok, task}` (이미 claim 시 `{ok:false, held_by}`) |
|
||||||
|
| 작업 | `update_task` | `(task_id, status, role, note?)` → `{ok, task}` (status ∈ open/in_progress/blocked/done) |
|
||||||
|
| 작업 | `list_tasks` | `(status?, assignee_role?)` → `{tasks:[...]}` |
|
||||||
|
| 락 | `acquire_lock` | `(resource, role, ttl_sec=300)` → `{acquired, held_by?, ttl_remaining?}` |
|
||||||
|
| 락 | `release_lock` | `(resource, role)` → `{released}` (소유자 아니면 `{released:false}`) |
|
||||||
|
| 락 | `heartbeat_lock` | `(resource, role, ttl_sec=300)` → `{renewed}` (소유자만) |
|
||||||
|
| 락 | `list_locks` | `()` → `{locks:[{resource, held_by, ttl_remaining}]}` |
|
||||||
|
| 가시성 | `team_log` | `(after_id?)` → `{events:[...], cursor}` (최근 활동 피드) |
|
||||||
|
|
||||||
|
## Redis 데이터 모델 (전부 원자적)
|
||||||
|
|
||||||
|
- **메시지**: `co:inbox:{role}` = Redis **Stream**. `post_message`=XADD, `read_inbox`=XREAD(`after_id` 커서, 비파괴). `mark_read`는 `co:read:{role}` 키에 마지막 id 저장.
|
||||||
|
- **작업**: `co:task:{id}` Hash(title/assignee/status/detail/created_by/ts), `co:tasks` Set(id 목록), `INCR co:taskseq`로 id. `claim_task`/`update_task`는 **Lua 스크립트**로 read-modify-write 원자화(중복 claim/경합 방지).
|
||||||
|
- **락**: 획득 = `SET co:lock:{resource} {role} NX EX {ttl}`(원자적). `release_lock`/`heartbeat_lock` = **Lua**로 `GET` 소유자 일치 확인 후 `DEL`/`EXPIRE`(check-and-act 원자화 → 남의 락 조작 불가).
|
||||||
|
- **활동로그**: `co:log` = 캡트 Stream(`XADD ... MAXLEN ~ 500`). 메시지·작업·락 이벤트 기록 → Producer 오버사이트.
|
||||||
|
|
||||||
|
## 동시 쓰기 분리 (핵심 요구)
|
||||||
|
|
||||||
|
**1차 — 정적 소유권 파티션** (락 불필요한 자연 분리):
|
||||||
|
- `web-ui` → FE만, `web-backend` → BE만, `web-ai` → AI만 쓰기. 각 세션은 자기 repo만 편집 → git 충돌 원천 차단.
|
||||||
|
|
||||||
|
**2차 — 교차 리소스 어드바이저리 락** (여러 역할이 건드릴 수 있는 민감 영역만):
|
||||||
|
- 예약 resource 명: `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`(web-ui↔web-ai 미러), `nginx-conf`, `compose`.
|
||||||
|
- 규약: 위 리소스 변경 전 `acquire_lock` 필수. 점유 중이면 `{acquired:false, held_by, ttl_remaining}` → 대기. **TTL 자동 해제로 세션 사망 시 데드락 방지**, 긴 작업은 `heartbeat_lock` 갱신.
|
||||||
|
- 어드바이저리(협조적): 버스는 FS를 강제 잠그지 않음 → 각 세션 CLAUDE.md에 "공유 리소스 = 락 먼저" 규약 명문화로 강제.
|
||||||
|
|
||||||
|
## 클라이언트 배선
|
||||||
|
|
||||||
|
- 각 repo `.mcp.json`:
|
||||||
|
```json
|
||||||
|
{ "mcpServers": { "co-gahusb": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" } } } }
|
||||||
|
```
|
||||||
|
(키는 커밋 금지 — 각 머신 env/로컬에서 주입. `.mcp.json`엔 placeholder, 실제 키는 `.env`/환경변수.)
|
||||||
|
- 각 repo CLAUDE.md에 역할 블록 추가: "너는 역할 X / 모든 co-gahusb 툴에 role=X / 공유 리소스 변경 전 acquire_lock / `/loop`로 inbox·tasks 폴링".
|
||||||
|
- web-ai는 다른 머신 → 해당 머신에서 `.mcp.json` 적용(스펙에 절차 명시).
|
||||||
|
|
||||||
|
## 인프라 등재 (신규 컨테이너 추가 의무 위치 — [[reference_nas_url_routing]], [[reference_deploy_nas_services_whitelist]])
|
||||||
|
|
||||||
|
1. `docker-compose.yml` — `co-gahusb` 서비스(build, `REDIS_URL`, `depends_on: redis`, `CO_BUS_KEY` env, `${RUNTIME_PATH}` 볼륨 불요(상태는 Redis)).
|
||||||
|
2. nginx `default.conf` — **public `location /api/co/`** 추가(7번째 등재 규칙; `/api/internal/` 불필요).
|
||||||
|
3. deploy 스크립트 SERVICES 화이트리스트에 `co-gahusb` 등재.
|
||||||
|
4. `${RUNTIME_PATH}` 절대경로 — 본 서비스는 영속 볼륨 없음(Redis 백엔드)이라 코드 디렉토리만.
|
||||||
|
5. frontend `depends_on` — 불필요(백엔드 전용 서비스).
|
||||||
|
6. `.env` — `CO_BUS_KEY` 추가(커밋 금지).
|
||||||
|
|
||||||
|
## 에러 / 엣지 처리
|
||||||
|
|
||||||
|
- 인증 실패 → 401, 1회만 ERROR 로그 후 조용([[reference_webai_auth_pattern]]).
|
||||||
|
- 락 획득 실패 → 예외 아닌 `{acquired:false, held_by, ttl_remaining}` 정상 반환.
|
||||||
|
- 만료 락 → Redis TTL 자동 소멸(별도 GC 불필요).
|
||||||
|
- 알 수 없는 role/resource → 명시적 에러 메시지.
|
||||||
|
- Redis 연결 실패 → 503 + 명확한 메시지.
|
||||||
|
|
||||||
|
## 테스트 (TDD, pytest + fakeredis)
|
||||||
|
|
||||||
|
- **락**: 두 역할 같은 resource 획득 → 2번째 거부 / TTL 만료 후 획득 / 소유자 아닌 release·heartbeat 거부 / heartbeat 갱신 후 ttl 증가.
|
||||||
|
- **메시지**: XADD 순서대로 `after_id` 커서 읽기 / mark_read 후 재읽기 시 제외 / 다른 role 우편함 격리.
|
||||||
|
- **작업**: create→claim(중복 claim 거부)→update status 전이 / list 필터.
|
||||||
|
- **인증**: 키 일치 통과 / 불일치 401.
|
||||||
|
- **team_log**: 이벤트 기록 + MAXLEN 캡.
|
||||||
|
|
||||||
|
## 구현 순서 (phase)
|
||||||
|
|
||||||
|
1. 스캐폴드: 디렉토리/Dockerfile/requirements/config (기존 lab 구조 미러)
|
||||||
|
2. `store.py` + `locks.py` (TDD, fakeredis) — 락 → 메시지 → 작업 → team_log
|
||||||
|
3. `server.py` — FastMCP 툴 등록 + Bearer 인증 + ASGI
|
||||||
|
4. 인프라 등재 6위치 (compose/nginx/deploy/env)
|
||||||
|
5. 클라이언트 배선: web-ui·web-backend `.mcp.json` + CLAUDE.md 역할 블록 (web-ai는 절차 문서화)
|
||||||
|
6. 배포(Gitea push → webhook) + 스모크 테스트(헬스/인증/락 경합)
|
||||||
|
|
||||||
|
## 비범위 (YAGNI)
|
||||||
|
|
||||||
|
- 실시간 push(텔레그램) — 후속. 우선 /loop 폴링.
|
||||||
|
- SQLite 감사로그 — Redis 캡트 스트림으로 충분.
|
||||||
|
- 웹 대시보드 — agent-office 오버사이트와 추후 통합 여지.
|
||||||
|
- 락의 FS 레벨 강제 — 어드바이저리로 충분(세션은 협조적).
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# 분산 워커 관측 시스템 (Distributed Worker Observability) — 설계 문서
|
||||||
|
|
||||||
|
> 작성일: 2026-06-29 · 작성 세션: BE (web-backend 소유)
|
||||||
|
> 대상 repo 3종: `web-ai`(워커) · `web-backend`(NAS 집계/경보) · `web-ui`(Three.js 대시보드)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 문제 정의 (Problem)
|
||||||
|
|
||||||
|
NAS 백엔드의 음악/영상/이미지/인스타 생성은 **무거운 작업을 Windows AI 머신(192.168.45.59)의 WSL2 Docker 워커**에 위임한다. NAS 게이트웨이(`music/video/image/insta-lab`)가 Redis 큐(`queue:<svc>-render`)에 job을 push하면, Windows 워커가 BLMOVE로 꺼내 처리하고 `/api/internal/<svc>/update` webhook으로 결과를 회신한다. 트레이딩봇 `ai_trade`(:8001)는 별도로 NAS stock(:18500)에서 HTTP pull을 한다.
|
||||||
|
|
||||||
|
**핵심 문제: 이 분산 워커들이 살아있는지 NAS·사용자가 알 길이 없다.**
|
||||||
|
- 각 워커에 로컬 `/health` 엔드포인트가 있으나 Windows 머신 안에서만 접근 가능.
|
||||||
|
- 실제 사고: `insta-render` 워커가 redis 블로킹 read 버그로 **2026-05-22 ~ 06-08 약 2주간 사일런트로 죽어 있었고**(모든 슬레이트 draft 정지) 아무도 몰랐다. 일감이 없을 때의 "한가함"과 "죽음"을 구분할 수단이 없었던 것이 근본 원인.
|
||||||
|
|
||||||
|
## 2. 목표 / 비목표 (Goals / Non-goals)
|
||||||
|
|
||||||
|
**목표 (Phase 1)**
|
||||||
|
- G1. 6개 워커(`music/video/image/insta-render` + `task-watcher` + `ai_trade`)의 생사·상태를 NAS에서 인지.
|
||||||
|
- G2. 큐 깊이·실패(dead-letter)·고아작업(processing)·일시정지(paused) 상태를 집계.
|
||||||
|
- G3. 상태 전이(다운/복구/실패누적)를 텔레그램으로 자동 경보.
|
||||||
|
- G4. web-ui 신규 페이지 `/infra`에서 NAS↔Windows 파이프라인을 **Three.js로 시각화** — 정상이면 통신이 흐르는 애니메이션, 장애면 해당 구간을 끊김/빨강으로 표시.
|
||||||
|
|
||||||
|
**비목표 (Phase 2 이후로 보류)**
|
||||||
|
- 원격 제어(워커 재시작, 큐 pause/resume, dead-letter 재처리) — Windows 머신 제어가 필요해 보안·구현 복잡도 큼.
|
||||||
|
- GPU 사용률(VRAM) 모니터링, stuck-task 자동 감지, WebSocket 라이브 푸시.
|
||||||
|
- 다중 노드 확장(현재 Windows 노드 1대).
|
||||||
|
|
||||||
|
## 3. 아키텍처 & 토폴로지
|
||||||
|
|
||||||
|
```
|
||||||
|
web-backend (NAS, 192.168.45.54) Windows 노드 (192.168.45.59)
|
||||||
|
┌──────────────────────────────────┐ ┌────────────────────────────────────┐
|
||||||
|
│ music-lab ─┐ │ ① job │ WSL2 Docker: │
|
||||||
|
│ video-lab ─┤ │ push │ ┌─ music-render │
|
||||||
|
│ image-lab ─┼─► [ Redis 큐 버스 ]═╪══════════╪══►├─ video-render (ReliableQueue) │
|
||||||
|
│ insta-lab ─┘ queue:*-render │ │ ├─ image-render │
|
||||||
|
│ queue:paused │◄═════════╪═══├─ insta-render │
|
||||||
|
│ │ ② webhook│ └─ task-watcher (paused 토글) │
|
||||||
|
│ agent-office │◄─────────╪── 각 워커 → worker:<name>:heartbeat│
|
||||||
|
│ ├─ node_monitor (집계) │◄─heartbeat (Redis SET, TTL 45s) │
|
||||||
|
│ └─ scheduler (1분 경보 cron) │ │ │
|
||||||
|
│ │ │ Windows 호스트(WSL 밖): │
|
||||||
|
│ stock (:18500) ◄── HTTP pull ────╪──────────╪── ai_trade (:8001) ─ heartbeat ───►│
|
||||||
|
└──────────────┬───────────────────┘ └────────────────────────────────────┘
|
||||||
|
│ GET /api/agent-office/nodes (FE 2~3초 폴링)
|
||||||
|
▼
|
||||||
|
web-ui /infra ← Three.js 파이프라인 시각화
|
||||||
|
```
|
||||||
|
|
||||||
|
**설계 기반(이미 존재하는 자산)**
|
||||||
|
- 워커들은 이미 NAS Redis(`redis://192.168.45.54:6379`)에 BLMOVE로 연결 → heartbeat도 같은 Redis에 SET하면 방화벽/인바운드 포트 불필요, `queue:paused`여도 heartbeat는 계속 뛰므로 "정지 중이지만 살아있음"과 "죽음"을 구분 가능.
|
||||||
|
- `_shared/reliable_queue.py`(ReliableQueue)가 이미 `processing:queue:<svc>-render:<worker_id>` 리스트와 `dead_letter:queue:<svc>-render` 리스트를 Redis에 남김 → 집계기가 **신규 워커 코드 없이** 큐 깊이·실패·고아작업을 읽을 수 있음.
|
||||||
|
|
||||||
|
**채택하지 않은 대안**
|
||||||
|
- 집계기를 게이트웨이 중 하나에 배치 → "어느 게이트웨이가 전체 노드 상태를 소유하나"가 의미상 어색. `agent-office`가 ops 브레인(텔레그램·스케줄러·WebSocket·서비스 로그 수집 보유)이라 의미상 정확.
|
||||||
|
- NAS→워커 HTTP `/health` 폴링 → 워커별 포트 노출 + NAS→Windows 인바운드 접속 필요. Redis heartbeat가 단방향(워커→Redis)이라 더 단순.
|
||||||
|
- 라이브 갱신을 WebSocket으로 → Phase 1은 2~3초 폴링으로 충분(단순). WebSocket은 Phase 2 강화.
|
||||||
|
|
||||||
|
## 4. 컴포넌트 설계
|
||||||
|
|
||||||
|
### 4.1 web-ai — heartbeat 생산자 (AI 세션 소유)
|
||||||
|
|
||||||
|
**4.1.1 render 워커 4종 (`services/*-render/`)**
|
||||||
|
- 신규 공용 모듈 `services/_shared/heartbeat.py`:
|
||||||
|
- `async def heartbeat_loop(redis, name, stats, interval=15, ttl=45)` — `interval`초마다 `worker:<name>:heartbeat` 키에 JSON 값을 `SET ... EX ttl`.
|
||||||
|
- 값 스키마는 §5.1 참조. 죽으면 키가 TTL 만료 → 집계기가 "missing = dead" 판정.
|
||||||
|
- 각 워커 `main.py` lifespan에서 `worker_loop`와 함께 `heartbeat_loop` 태스크 spawn.
|
||||||
|
- `state` 산정: `queue:paused`가 set이면 `paused`, 현재 job 처리 중이면 `busy`, 아니면 `idle`. 처리 중 여부와 카운터(`jobs_done`/`jobs_failed`/`last_job_at`)는 `poll_once`가 갱신하는 모듈 레벨 `stats` 객체로 추적.
|
||||||
|
- TTL=45s = interval(15s)의 3배 → 1~2회 누락은 dead로 오판하지 않음.
|
||||||
|
|
||||||
|
**4.1.2 task-watcher (`services/task-watcher/`)**
|
||||||
|
- `watcher_loop`에 동일 heartbeat 추가. `worker:task-watcher:heartbeat`에 `state` + 현재 `mode`(`trading`/`free`)를 함께 발행 → 대시보드가 paused의 **이유**("작업중(트레이딩)")를 표시.
|
||||||
|
|
||||||
|
**4.1.3 ai_trade (`ai_trade/`) — 다른 런타임**
|
||||||
|
- ai_trade는 Windows **호스트**에서 직접 uvicorn 실행(WSL Docker 아님), NAS Redis 큐에 연결되어 있지 않음(현재 NAS stock으로 HTTP pull만).
|
||||||
|
- 변경: `redis.asyncio` 의존성 추가 → `main.py` lifespan에 heartbeat 태스크 추가 → 같은 NAS Redis(`192.168.45.54:6379`)에 `worker:ai_trade:heartbeat` SET.
|
||||||
|
- Redis는 Windows 머신에서 이미 도달 가능(render 워커들이 같은 호스트에서 BLMOVE 중).
|
||||||
|
- heartbeat 로직은 ~10줄이므로 `ai_trade` 자체 미니 헬퍼로 둔다(`_shared` import 경로 의존 회피 — render 워커는 컨테이너 PYTHONPATH로 `_shared` 접근, ai_trade는 호스트 실행이라 경로가 다름). **계약(키 스키마)만 동일**하면 코드 공유 불필요.
|
||||||
|
- `state` 의미가 다름: render 워커의 idle/busy/paused가 아니라 `market_open`(poll_loop 활성·신호 생성 중) / `market_closed`(휴장·장외 idle). **task-watcher의 `queue:paused`와 무관**(트레이딩은 일시정지 대상 아님).
|
||||||
|
- 토폴로지 표현: Redis 큐 버스가 아니라 **HTTP pull 파이프라인**(ai_trade ⇄ NAS stock :18500)으로 별도 표시.
|
||||||
|
|
||||||
|
### 4.2 web-backend / agent-office — 집계기 + 경보 (이 BE 세션 소유)
|
||||||
|
|
||||||
|
**4.2.1 Redis 클라이언트 추가**
|
||||||
|
- `agent-office`는 현재 Redis 미사용 → `requirements.txt`에 `redis>=5.0`(asyncio) 추가, `docker-compose.yml` agent-office 블록에 `REDIS_URL` 환경변수 + `depends_on: redis` 추가.
|
||||||
|
|
||||||
|
**4.2.2 `app/node_monitor.py` 신규**
|
||||||
|
- 워커 레지스트리(상수): 각 워커의 `name`, 연관 `queue`(있으면), `internal webhook` 경로, 토폴로지 link 타입(`redis-queue` | `http-pull`).
|
||||||
|
- `async def collect_status() -> dict`:
|
||||||
|
- 각 워커: `GET worker:<name>:heartbeat` → 존재하면 `alive=True` + JSON 파싱 + `last_beat_age_s = now - ts`; 없으면 `alive=False`(dead).
|
||||||
|
- 각 render 큐: `LLEN queue:<svc>-render`(depth), `LLEN dead_letter:queue:<svc>-render`, `processing:queue:<svc>-render:*` 키 스캔으로 in-flight 수.
|
||||||
|
- `GET queue:paused` + TTL → paused 플래그 + reason(task-watcher heartbeat의 mode).
|
||||||
|
- Redis 연결 실패 → `redis_ok=False`(전 구간 degrade).
|
||||||
|
- link 상태 합성(§5.2).
|
||||||
|
- 응답 스키마는 §5.2.
|
||||||
|
|
||||||
|
**4.2.3 엔드포인트**
|
||||||
|
- `GET /api/agent-office/nodes` → `collect_status()`. nginx `/api/agent-office/` 이미 라우팅됨 → **nginx 변경 불필요**.
|
||||||
|
|
||||||
|
**4.2.4 경보 cron (scheduler)**
|
||||||
|
- `_run_node_health_check` (APScheduler, 1분 간격):
|
||||||
|
- 직전 상태 `_node_state`(인메모리 dict)와 비교:
|
||||||
|
- `alive → dead`: 🔴 `<name> 워커 다운 (last beat Xs ago)`
|
||||||
|
- `dead → alive`: 🟢 `<name> 워커 복구`
|
||||||
|
- `dead_letter` 카운트가 임계(`NODE_ALERT_DEADLETTER_THRESHOLD`, 기본 1) 신규 초과: ❌ `<queue> 실패 누적 N건`
|
||||||
|
- `_notified` 패턴(기존 `youtube_publisher.poll_state_changes` 재사용)으로 스팸 방지, 복구 시 재알림 가능하도록 set 차집합.
|
||||||
|
- 텔레그램 발송은 agent-office 기존 봇 재사용.
|
||||||
|
|
||||||
|
### 4.3 web-ui — Three.js 대시보드 (FE 세션 소유)
|
||||||
|
|
||||||
|
- 신규 의존성: `three` + `@react-three/fiber` + `@react-three/drei`(React 코드베이스이므로 r3f가 관용적).
|
||||||
|
- 신규 라우트 `/infra`(Router.jsx) + Nav 등록.
|
||||||
|
- `pages/infra/InfraMonitor.jsx`:
|
||||||
|
- r3f `<Canvas>` 토폴로지 — 좌측 NAS(게이트웨이 sub-node) / 중앙 Redis 큐 버스(글로우 코어) / 우측 Windows 노드(워커 sub-node). ai_trade는 별도 HTTP-pull 파이프라인.
|
||||||
|
- 노드 간 파이프라인(튜브) + 상태별 머티리얼/애니메이션(§6).
|
||||||
|
- `useNodeStatus` 훅: `GET /api/agent-office/nodes`를 2~3초 폴링 → 상태를 시각 상태로 매핑(`src/api.js`에 헬퍼 추가).
|
||||||
|
- **2D 폴백**: WebGL 미지원/모바일 대비 카드·테이블 요약 뷰 토글.
|
||||||
|
- 실제 구현 시 `designer` 스킬 활성화(브레인스토밍 단계에서는 금지).
|
||||||
|
|
||||||
|
## 5. 잠그는 계약 (Contracts)
|
||||||
|
|
||||||
|
> 3 세션이 독립 병렬 작업하려면 이 두 스키마만 고정하면 된다.
|
||||||
|
|
||||||
|
### 5.1 Heartbeat 키 스키마
|
||||||
|
|
||||||
|
- **키**: `worker:<name>:heartbeat` (name ∈ `music-render`, `video-render`, `image-render`, `insta-render`, `task-watcher`, `ai_trade`)
|
||||||
|
- **값**(JSON 문자열), `SET ... EX 45`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "image-render",
|
||||||
|
"kind": "render", // "render" | "watcher" | "trader"
|
||||||
|
"state": "idle", // render: idle|busy|paused / watcher: trading|free / trader: market_open|market_closed
|
||||||
|
"ts": "2026-06-29T12:34:56Z", // UTC ISO8601 (heartbeat 발신 시각)
|
||||||
|
"last_job_at": "2026-06-29T12:30:00Z", // nullable
|
||||||
|
"jobs_done": 42,
|
||||||
|
"jobs_failed": 1,
|
||||||
|
"mode": "free" // task-watcher 전용(paused 이유), 그 외 생략 가능
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `/api/agent-office/nodes` 응답 스키마
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"redis_ok": true,
|
||||||
|
"paused": false,
|
||||||
|
"paused_reason": "trading", // queue:paused가 set일 때 task-watcher mode
|
||||||
|
"generated_at": "2026-06-29T12:34:57Z",
|
||||||
|
"workers": [
|
||||||
|
{
|
||||||
|
"name": "image-render", "kind": "render",
|
||||||
|
"alive": true, "state": "idle", "last_beat_age_s": 3,
|
||||||
|
"queue_depth": 0, "dead_letter": 0, "processing": 0,
|
||||||
|
"jobs_done": 42, "jobs_failed": 1, "last_job_at": "2026-06-29T12:30:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{ "from": "nas", "to": "image-render", "type": "redis-queue", "status": "healthy" },
|
||||||
|
{ "from": "ai_trade", "to": "nas-stock", "type": "http-pull", "status": "healthy" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `link.status` ∈ `healthy` | `paused` | `down` | `degraded`. 산정: 워커 dead → `down`; paused → `paused`; dead_letter>0 → `degraded`; redis_ok=false → 전 링크 `down`.
|
||||||
|
|
||||||
|
## 6. 시각화 상태 (Three.js)
|
||||||
|
|
||||||
|
| 상태 | 파이프라인(튜브) | 노드 |
|
||||||
|
|------|------------------|------|
|
||||||
|
| **정상 idle** | 시안/그린, 파티클이 NAS→워커→NAS 루프로 흐름(느림) | 초록 글로우 + 큐깊이/처리수 HUD |
|
||||||
|
| **정상 busy** | 파티클 빠르게 흐름 | "처리 중 N" |
|
||||||
|
| **일시정지 paused** | 앰버, 파티클 느려짐/정지 | "⏸ 작업중(트레이딩)" 라벨 |
|
||||||
|
| **장애 dead / link down** | 빨강, 흐름 멈춤, 끊긴 지점 스파크/단절 | 빨강 + ⚠ 경고, "last beat Xs ago" |
|
||||||
|
| **실패누적 dead-letter>0** | 해당 튜브 ❌ 뱃지 | dead-letter 카운트 강조 |
|
||||||
|
| **Redis/집계기 다운** | 중앙 버스 전체 빨강 | "집계 서버 연결 끊김" 오버레이 |
|
||||||
|
|
||||||
|
- ai_trade의 HTTP-pull 파이프라인은 큐 흐름이 아닌 pull 방향(ai_trade→NAS stock) 파티클로 구분 표현. `market_closed`는 정상 idle과 동일 톤(휴장은 장애 아님).
|
||||||
|
|
||||||
|
## 7. 에러 처리
|
||||||
|
|
||||||
|
- heartbeat TTL 만료 = dead 판정(권위 신호). 큐가 비어 일감이 없어도 heartbeat가 살아있으면 alive로 정확 판정(2주 사일런트 사고 재발 방지).
|
||||||
|
- Redis 다운 → `/nodes`가 `redis_ok=false` 반환(500 아님) → 대시보드가 전 구간 degrade 표시.
|
||||||
|
- agent-office 다운 → FE 폴링 실패 → "집계 서버 연결 끊김" 오버레이.
|
||||||
|
- 집계기는 read-only(Redis에 쓰지 않음) → 워커 동작에 영향 0.
|
||||||
|
|
||||||
|
## 8. 테스트
|
||||||
|
|
||||||
|
- **web-ai**: `heartbeat.py` 단위 테스트(fakeredis/mock) — 발신 주기·TTL·state 전이·카운터. ai_trade heartbeat 별도 테스트.
|
||||||
|
- **web-backend**: `node_monitor.collect_status` 테스트(mock redis: 키 존재/만료/큐 깊이/dead-letter 케이스) + 경보 전이 테스트(alive→dead→alive, dead-letter 증가). TDD 적용.
|
||||||
|
- **web-ui**: `InfraMonitor` 컴포넌트가 mock 상태로 렌더 + 상태→색상 매핑 단위 테스트(r3f는 렌더 스모크 수준).
|
||||||
|
|
||||||
|
## 9. 단계 (Phasing)
|
||||||
|
|
||||||
|
- **Phase 1 (본 스펙 전체)**: 6 워커(render 4 + task-watcher + ai_trade) heartbeat / `/nodes` API / 텔레그램 경보 / Three.js `/infra` 대시보드.
|
||||||
|
- **Phase 2 (후속)**: GPU 사용률(VRAM 16GB 경합 가시화), stuck-task 감지, WebSocket 라이브 푸시, 원격 제어(워커 재시작·pause/resume·dead-letter 재처리).
|
||||||
|
|
||||||
|
## 10. 세션 분담 & 협업 (co-gahusb)
|
||||||
|
|
||||||
|
- **소유권**: BE(이 세션)=web-backend, AI 세션=web-ai, FE 세션=web-ui. 각자 자기 repo만 커밋.
|
||||||
|
- **선행 게이트**: §5의 두 계약(heartbeat 키 스키마 + `/nodes` 응답 스키마)을 먼저 확정·공유 → 3 세션 병렬 진행.
|
||||||
|
- **공유 리소스 락**: agent-office 의존성/compose 변경은 `compose` 락, nginx 무변경(불필요). 배포는 `nas-deploy` 락.
|
||||||
|
- BE 작업: agent-office redis 추가 + `node_monitor.py` + `/nodes` + 경보 cron + 본 메모리 기록. AI/FE 작업은 co-gahusb 태스크로 배분.
|
||||||
|
|
||||||
|
## 11. 메모리 갱신 계획
|
||||||
|
|
||||||
|
- 신규 cross-cutting 메모리 `infra_distributed_workers.md` 작성: 큐 계약 / webhook 계약 / ReliableQueue 키 / heartbeat 키 스키마 / task-watcher paused / node_monitor·`/nodes`·경보. `MEMORY.md` 인덱스 등재.
|
||||||
|
- 관련 서비스 메모리(`service_video/image/music/insta`)에 heartbeat·관측 추가 사실을 cross-link.
|
||||||
|
```
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# 실시간 매매 알람 (Real-time Trade Alerts) — 설계 스펙
|
||||||
|
|
||||||
|
- 작성일: 2026-07-02
|
||||||
|
- 상태: 설계 승인됨 (사용자 리뷰 대기)
|
||||||
|
- 관련 세션: BE(web-backend, 본 스펙 주도) · AI(web-ai 워커) · FE(web-ui 탭)
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
장이 열려 있는 동안(**시간외 포함**) 실시간으로 주가 기준치를 분석해, 조건 충족 시 **매수/매도 알람**을 텔레그램으로 **사용자 + 아내** 둘 다에게 전송한다. 기술적 분석(TA) 계산은 **Windows PC의 docker 워커**에서 수행한다.
|
||||||
|
|
||||||
|
기존에는 이 판단들이 EOD(하루 1회)로만 돌았다:
|
||||||
|
- 매수 후보 = 스크리너(평일 16:30) · 매도/보유 advisory = holdings_intel(08:30/16:50).
|
||||||
|
|
||||||
|
이번 작업의 핵심 = **동일 판단을 장중(+시간외) 1분 주기 실시간으로 전환 + 조건 충족 즉시 알람**.
|
||||||
|
|
||||||
|
## 2. 확정된 요구사항 (사용자 결정)
|
||||||
|
|
||||||
|
| 항목 | 결정 |
|
||||||
|
|------|------|
|
||||||
|
| 매수 유니버스 | **watchlist(사용자 관리) ∪ 당일 스크리너 후보** |
|
||||||
|
| 매수 트리거 | **TA 자동 시그널**(수동 목표가 없음) |
|
||||||
|
| 매도 트리거 | **기존 exit 룰 + 트레일링 스톱** |
|
||||||
|
| 감시 주기/세션 | **1분 폴링** · 장전 시간외 08:30–09:00 · 정규장 09:00–15:30 · 시간외 단일가 16:00–18:00 |
|
||||||
|
| 중복 방지 | **상태 전이(edge-triggered)** — 거짓→참 전이 시만 알림, 참 유지 중 무알림, 재무장 |
|
||||||
|
| watchlist 관리 | **텔레그램 봇 명령 + web-ui 탭 둘 다** |
|
||||||
|
| 수신자 | **사용자 + 아내 둘 다**(매수·매도 모두) |
|
||||||
|
| TA 연산 위치 | **Windows WSL2 docker 신규 워커** |
|
||||||
|
| 트레일링 스톱 기본값 | 보유기간 고점 대비 **−10%**(파라미터화) |
|
||||||
|
| 매수 신호 | 지지선 되돌림(MA20/50) · 돌파(전고점/52주) · RSI 과매도 반등 |
|
||||||
|
|
||||||
|
## 3. 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
[Windows WSL2 docker] trade-monitor 워커 (web-ai · AI세션)
|
||||||
|
1분 루프 (KST 세션 게이팅)
|
||||||
|
① GET NAS /api/webai/trade-alert/monitor-set (X-WebAI-Key)
|
||||||
|
② KIS 실시간/시간외 시세 + 분봉/일봉 → TA 계산
|
||||||
|
③ 조건 평가 → 현재 발화집합 F = {(ticker, kind, condition)}
|
||||||
|
④ POST NAS /api/webai/trade-alert/report {firing: F} (X-WebAI-Key)
|
||||||
|
⑤ heartbeat: worker:trade-monitor:heartbeat (EX45, 관측 편입)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[NAS] stock (:18500 · web-backend · BE)
|
||||||
|
• watchlist·alert_state(edge dedup, 영속)·alert_history·holding high-water
|
||||||
|
• monitor-set 조립(watchlist ∪ screener 후보 ∪ 보유) + 세션/휴장 게이팅
|
||||||
|
• report 수신 → edge diff(F vs 직전 발화) → 신규 edge를 agent-office로 push
|
||||||
|
│ (텔레그램 전송 성공 시에만 alert_state 갱신)
|
||||||
|
▼
|
||||||
|
[NAS] agent-office (:18900 · web-backend · BE)
|
||||||
|
• POST /api/agent-office/stock/trade-alert → 텔레그램(너+아내)
|
||||||
|
• 봇 명령 /watch /unwatch /watchlist → stock watchlist CRUD
|
||||||
|
• 알람 activity feed 편입
|
||||||
|
|
||||||
|
[web-ui] 관심종목 탭 (FE세션) — watchlist CRUD + 알람 이력 뷰
|
||||||
|
```
|
||||||
|
|
||||||
|
**설계 원칙**
|
||||||
|
- TA/조건판정 = Windows(요구사항). **edge 중복판정 상태 = NAS 영속** → 워커 재시작해도 재알림 스팸 없음(youtube_publisher 교훈 재적용).
|
||||||
|
- 워커는 dedup 상태를 **안 가진다**. 매 사이클 "현재 발화집합 전체"만 보고 → NAS가 diff(단일 진실원천).
|
||||||
|
- 워커의 대외 채널은 **NAS stock 한 곳**(기존 ai_trade↔stock의 `X-WebAI-Key` 재사용). 텔레그램 발송은 stock→agent-office push(기존 realestate→agent-office/notify 패턴).
|
||||||
|
|
||||||
|
## 4. DB 스키마 (stock.db)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 매수 감시 관심종목 (사용자 관리)
|
||||||
|
CREATE TABLE IF NOT EXISTS watchlist (
|
||||||
|
ticker TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
note TEXT,
|
||||||
|
params_json TEXT NOT NULL DEFAULT '{}', -- 종목별 조건 오버라이드(선택)
|
||||||
|
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- edge 중복판정 상태 (영속 — 재시작 스팸 방지의 핵심)
|
||||||
|
CREATE TABLE IF NOT EXISTS trade_alert_state (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- 'buy' | 'sell'
|
||||||
|
condition TEXT NOT NULL, -- ex) buy_ma20_pullback, sell_trailing_stop
|
||||||
|
currently_firing INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_fired_at TEXT,
|
||||||
|
last_fired_at TEXT,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
PRIMARY KEY (ticker, kind, condition)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 알람 이력
|
||||||
|
CREATE TABLE IF NOT EXISTS trade_alert_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
condition TEXT NOT NULL,
|
||||||
|
price REAL,
|
||||||
|
detail_json TEXT,
|
||||||
|
fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tah_fired ON trade_alert_history(fired_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
보유기간 고점(트레일링 스톱용) high-water는 `krx_daily_prices`(기존)에서 lookback max로 계산하거나 별도 컬럼으로 관리 — 구현 계획에서 확정(v1: 포지션 최초 관측 이후 일봉 고가 max, 없으면 최근 N일).
|
||||||
|
|
||||||
|
## 5. 계약 (Contracts) — cross-repo 잠금 대상
|
||||||
|
|
||||||
|
### 5.1 NAS stock ↔ Windows 워커 (X-WebAI-Key)
|
||||||
|
|
||||||
|
`GET /api/webai/trade-alert/monitor-set`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session": "pre | regular | after | closed",
|
||||||
|
"as_of": "2026-07-02T09:01:00+09:00",
|
||||||
|
"buy_targets": [{"ticker":"005930","name":"삼성전자","source":"watch|screener","params":{}}],
|
||||||
|
"sell_targets": [{"ticker":"000660","name":"SK하이닉스","avg_price":180000,"qty":10,
|
||||||
|
"holding_high":210000,"params":{}}],
|
||||||
|
"buy_params": {"rsi_oversold":30,"breakout_vol_mult":1.5,"pullback_pct":0.02},
|
||||||
|
"exit_params": {"stop_pct":0.08,"take_pct":0.25,"trailing_pct":0.10}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `session=closed`면 워커는 KIS 호출 없이 sleep.
|
||||||
|
|
||||||
|
`POST /api/webai/trade-alert/report`
|
||||||
|
```json
|
||||||
|
{ "as_of":"2026-07-02T09:01:00+09:00",
|
||||||
|
"firing":[ {"ticker":"005930","kind":"buy","condition":"buy_ma20_pullback",
|
||||||
|
"price":71500,"detail":{"ma20":71200,"rsi":34}} ] }
|
||||||
|
```
|
||||||
|
응답: `{ "new_alerts": <int>, "cleared": <int> }`
|
||||||
|
- NAS가 `firing` vs `trade_alert_state[firing=1]` diff → 신규 edge만 텔레그램.
|
||||||
|
|
||||||
|
### 5.2 stock → agent-office (내부)
|
||||||
|
|
||||||
|
`POST /api/agent-office/stock/trade-alert`
|
||||||
|
```json
|
||||||
|
{ "alerts":[ {"ticker":"005930","name":"삼성전자","kind":"buy",
|
||||||
|
"condition":"buy_ma20_pullback","price":71500,
|
||||||
|
"detail":{...},"fired_at":"..."} ] }
|
||||||
|
```
|
||||||
|
→ agent-office가 너+아내에게 텔레그램. (realestate/notify 패턴)
|
||||||
|
|
||||||
|
### 5.3 stock watchlist CRUD (web-ui + agent-office 봇)
|
||||||
|
- `GET /api/stock/watchlist`
|
||||||
|
- `POST /api/stock/watchlist` `{ticker, note?}`
|
||||||
|
- `DELETE /api/stock/watchlist/{ticker}`
|
||||||
|
- `GET /api/stock/trade-alerts?days=N` (이력, web-ui용)
|
||||||
|
|
||||||
|
### 5.4 워커 heartbeat (관측 편입)
|
||||||
|
`worker:trade-monitor:heartbeat` EX45, 값 JSON `{name:"trade-monitor",kind:"trader",state:"market_open|market_closed|idle",ts,last_alert_at,...}`. `/api/agent-office/nodes` workers[]에 추가.
|
||||||
|
|
||||||
|
## 6. 알람 조건 (Windows 워커가 계산)
|
||||||
|
|
||||||
|
**매수** (buy_targets):
|
||||||
|
- `buy_ma20_pullback` — MA20>MA50>MA200 정렬 + 저가가 MA20/50에 `pullback_pct` 이내 접근 후 종가 반등
|
||||||
|
- `buy_breakout` — 종가 > (전 N일 고점 또는 52주 신고가) + 거래량 > `breakout_vol_mult`×20일평균
|
||||||
|
- `buy_rsi_bounce` — RSI(14)가 `rsi_oversold` 아래로 내려갔다가 **봉 시리즈 내에서** 다시 상향 돌파(최근 봉에서 30 상향 크로스). 워커는 무상태 — 매 사이클 봉 데이터로 크로스를 계산(cross-cycle 메모리 불필요)
|
||||||
|
|
||||||
|
**매도** (sell_targets):
|
||||||
|
- `sell_stop_loss` — (price−avg)/avg ≤ −`stop_pct`
|
||||||
|
- `sell_ma_break` — 종가 < MA50 (심각: < MA200)
|
||||||
|
- `sell_take_profit` — (price−avg)/avg ≥ `take_pct`
|
||||||
|
- `sell_climax` — 급등 소진(holdings_intel climax 로직 이식)
|
||||||
|
- `sell_trailing_stop` — price ≤ holding_high × (1 − `trailing_pct`)
|
||||||
|
|
||||||
|
## 7. 데이터 흐름 — edge dedup (NAS)
|
||||||
|
|
||||||
|
```
|
||||||
|
매 1분 report 수신 시:
|
||||||
|
F = report.firing 집합
|
||||||
|
prev = SELECT (ticker,kind,condition) FROM trade_alert_state WHERE currently_firing=1
|
||||||
|
new_edge = F − prev
|
||||||
|
cleared = prev − F
|
||||||
|
for e in new_edge:
|
||||||
|
ok = agent_office.send_trade_alert(e) # 텔레그램
|
||||||
|
if ok:
|
||||||
|
INSERT trade_alert_history(e)
|
||||||
|
UPSERT trade_alert_state(e, firing=1, fired/last=now)
|
||||||
|
# 실패 시 상태 미갱신 → 다음 사이클 재시도
|
||||||
|
for c in cleared:
|
||||||
|
UPDATE trade_alert_state SET firing=0 WHERE key=c # 재무장
|
||||||
|
UPDATE last_seen_at for all F
|
||||||
|
```
|
||||||
|
- 영속 `trade_alert_state` → 워커·NAS 재시작에도 재알림 스팸 없음.
|
||||||
|
- 텔레그램 실패 시 firing 미표시 → 재시도 보장(node_monitor "성공 시만 갱신" 관용).
|
||||||
|
|
||||||
|
## 8. 세션/휴장 게이팅
|
||||||
|
|
||||||
|
NAS `monitor-set.session` 필드가 KST 시각 + `holidays.json`(`is_market_open`)으로 판정:
|
||||||
|
- pre 08:30–09:00 / regular 09:00–15:30 / after 16:00–18:00 → 그 외/휴장 = closed.
|
||||||
|
- 워커는 `closed`면 sleep. (불필요 KIS 호출·알람 차단)
|
||||||
|
|
||||||
|
## 9. 에러 처리
|
||||||
|
|
||||||
|
- 워커: KIS 실패 → 해당 사이클 skip + 다음 분 재시도, 종목별 실패 격리. heartbeat로 생사 노출.
|
||||||
|
- NAS: 워커 인증 `X-WebAI-Key`. 텔레그램 실패 → 상태 미갱신. `report`는 멱등(같은 F 재전송 무해).
|
||||||
|
- 워커 다운 시 알람 정지 → node_monitor 경보(기존 관측)로 감지.
|
||||||
|
|
||||||
|
## 10. 테스트 전략 (BE, TDD)
|
||||||
|
|
||||||
|
- watchlist CRUD (추가/중복/삭제/조회)
|
||||||
|
- monitor-set 조립 (watchlist ∪ screener ∪ 보유, 세션 게이팅, 휴장)
|
||||||
|
- **edge diff 로직**: 신규 edge만 알림 / 참 유지 무알림 / 해제 후 재발화 재알림 / 재시작 지속성(영속 상태)
|
||||||
|
- 텔레그램 전송 실패 시 상태 미갱신(재시도)
|
||||||
|
- alert_history 기록 / trade-alerts 조회
|
||||||
|
- agent-office: /watch·/unwatch·/watchlist 봇 명령 → stock CRUD, trade-alert notify → 텔레그램 포맷(너+아내)
|
||||||
|
- webai 계약 엔드포인트(monitor-set/report) 스키마·인증
|
||||||
|
|
||||||
|
## 11. 작업 분담
|
||||||
|
|
||||||
|
| repo | 세션 | 산출물 | 상태 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| **web-backend** (stock + agent-office) | **BE(본 세션)** | DB·watchlist·edge·webai 계약·텔레그램·봇 | 이번에 구현 |
|
||||||
|
| **web-ai** (`services/trade-monitor/` WSL2 docker) | AI세션 | 1분 루프·KIS·TA·조건평가·report·heartbeat | 계약 넘김 |
|
||||||
|
| **web-ui** (관심종목 탭) | FE세션 | watchlist CRUD·조건·이력 뷰 | 계약 넘김 |
|
||||||
|
|
||||||
|
- 계약(§5)은 co-gahusb로 잠근 뒤 3세션 병렬.
|
||||||
|
- 워커 재빌드는 로컬 docker(사용자): `wsl -d Ubuntu-24.04 -- docker compose up -d --build trade-monitor`.
|
||||||
|
|
||||||
|
## 12. 범위 밖 (YAGNI / 후속)
|
||||||
|
- 실주문 자동 집행(알람 전용, KIS 주문 X).
|
||||||
|
- KIS 웹소켓 실시간 틱(1분 폴링으로 충분).
|
||||||
|
- 종목별 수동 목표가(이번은 TA 자동만).
|
||||||
|
- 백테스트/성과 추적(후속 슬라이스).
|
||||||
@@ -1100,6 +1100,19 @@ def get_pipeline(pid: int) -> Optional[Dict[str, Any]]:
|
|||||||
return _parse_pipeline_row(row)
|
return _parse_pipeline_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_pipeline(pid: int) -> bool:
|
||||||
|
"""파이프라인과 자식행(pipeline_feedback, pipeline_jobs)을 하드 삭제.
|
||||||
|
|
||||||
|
SQLite FK를 강제하지 않으므로 자식행을 명시적으로 먼저 삭제한다.
|
||||||
|
파이프라인이 존재했으면 True, 없었으면 False.
|
||||||
|
"""
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute("DELETE FROM pipeline_feedback WHERE pipeline_id = ?", (pid,))
|
||||||
|
conn.execute("DELETE FROM pipeline_jobs WHERE pipeline_id = ?", (pid,))
|
||||||
|
cur = conn.execute("DELETE FROM video_pipelines WHERE id = ?", (pid,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def update_pipeline_state(pid: int, state: str, **fields) -> None:
|
def update_pipeline_state(pid: int, state: str, **fields) -> None:
|
||||||
"""파이프라인 state를 갱신하고 옵션 컬럼을 함께 업데이트한다.
|
"""파이프라인 state를 갱신하고 옵션 컬럼을 함께 업데이트한다.
|
||||||
|
|
||||||
|
|||||||
@@ -1133,6 +1133,14 @@ def cancel_pipeline(pid: int):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/music/pipeline/{pid}")
|
||||||
|
def delete_pipeline_endpoint(pid: int):
|
||||||
|
"""파이프라인 행을 하드 삭제(전체 목록에서 완전 제거). 없으면 404."""
|
||||||
|
if not _db_module.delete_pipeline(pid):
|
||||||
|
raise HTTPException(404)
|
||||||
|
return {"ok": True, "deleted": pid}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/music/pipeline/{pid}/retry", status_code=202)
|
@app.post("/api/music/pipeline/{pid}/retry", status_code=202)
|
||||||
async def retry_pipeline(pid: int, bg: BackgroundTasks):
|
async def retry_pipeline(pid: int, bg: BackgroundTasks):
|
||||||
from .pipeline.state_machine import STEPS
|
from .pipeline.state_machine import STEPS
|
||||||
|
|||||||
@@ -105,6 +105,29 @@ def test_cancel_pipeline(client):
|
|||||||
assert db.get_pipeline(pid)["state"] == "cancelled"
|
assert db.get_pipeline(pid)["state"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_pipeline_removes_from_db(client):
|
||||||
|
pid = client.post("/api/music/pipeline", json={"track_id": 1}).json()["id"]
|
||||||
|
r = client.request("DELETE", f"/api/music/pipeline/{pid}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["ok"] is True
|
||||||
|
assert db.get_pipeline(pid) is None
|
||||||
|
all_ids = [p["id"] for p in client.get("/api/music/pipeline?status=all").json()["pipelines"]]
|
||||||
|
assert pid not in all_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_pipeline_not_found_returns_404(client):
|
||||||
|
r = client.request("DELETE", "/api/music/pipeline/99999")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_pipeline_removes_child_jobs(client):
|
||||||
|
pid = client.post("/api/music/pipeline", json={"track_id": 1}).json()["id"]
|
||||||
|
db.create_pipeline_job(pid, "cover")
|
||||||
|
assert len(db.list_pipeline_jobs(pid)) == 1
|
||||||
|
client.request("DELETE", f"/api/music/pipeline/{pid}")
|
||||||
|
assert db.list_pipeline_jobs(pid) == []
|
||||||
|
|
||||||
|
|
||||||
def test_setup_get_returns_defaults(client):
|
def test_setup_get_returns_defaults(client):
|
||||||
r = client.get("/api/music/setup")
|
r = client.get("/api/music/setup")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|||||||
@@ -119,6 +119,23 @@ server {
|
|||||||
proxy_connect_timeout 10s;
|
proxy_connect_timeout 10s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# naver-fetch 워커 → NAS realestate-lab internal webhook (targets/listings-ingest)
|
||||||
|
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
|
||||||
|
# Layer 3: X-Internal-Key (FastAPI dependency)
|
||||||
|
location /api/internal/realestate/ {
|
||||||
|
allow 192.168.45.0/24;
|
||||||
|
allow 100.64.0.0/10;
|
||||||
|
allow 127.0.0.1;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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-Internal-Key $http_x_internal_key;
|
||||||
|
proxy_pass http://realestate-lab:8000/api/internal/realestate/;
|
||||||
|
}
|
||||||
|
|
||||||
# realestate API
|
# realestate API
|
||||||
location /api/realestate/ {
|
location /api/realestate/ {
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -400,6 +417,20 @@ server {
|
|||||||
proxy_pass http://$saju_backend$request_uri;
|
proxy_pass http://$saju_backend$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# co-gahusb — FastMCP streamable-http bus
|
||||||
|
# Authorization forward required (Bearer key auth), no buffering, long read timeout
|
||||||
|
# trailing slash on proxy_pass strips /api/co/ prefix: /api/co/mcp → /mcp
|
||||||
|
location /api/co/ {
|
||||||
|
proxy_pass http://co-gahusb:8000/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
# agent-office API + WebSocket
|
# agent-office API + WebSocket
|
||||||
location /api/agent-office/ {
|
location /api/agent-office/ {
|
||||||
resolver 127.0.0.11 valid=10s;
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
|||||||
11
realestate-lab/app/auth.py
Normal file
11
realestate-lab/app/auth.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Windows naver-fetch 워커 → NAS realestate-lab 내부 엔드포인트 인증."""
|
||||||
|
import os
|
||||||
|
from fastapi import Header, HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def verify_internal_key(x_internal_key: str = Header(...)):
|
||||||
|
expected = os.getenv("INTERNAL_API_KEY")
|
||||||
|
if not expected:
|
||||||
|
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
|
||||||
|
if x_internal_key != expected:
|
||||||
|
raise HTTPException(401, "Invalid X-Internal-Key")
|
||||||
@@ -10,6 +10,9 @@ logger = logging.getLogger("realestate-lab")
|
|||||||
|
|
||||||
DB_PATH = os.getenv("REALESTATE_DB_PATH", "/app/data/realestate.db")
|
DB_PATH = os.getenv("REALESTATE_DB_PATH", "/app/data/realestate.db")
|
||||||
|
|
||||||
|
# listing_matcher.MIN_SAMPLE와 동일 값 유지 — 시세 표본이 이 미만이면 광역 폴백.
|
||||||
|
_MARKET_SAMPLE_MIN = 3
|
||||||
|
|
||||||
|
|
||||||
def _conn():
|
def _conn():
|
||||||
c = sqlite3.connect(DB_PATH, timeout=120.0)
|
c = sqlite3.connect(DB_PATH, timeout=120.0)
|
||||||
@@ -177,6 +180,72 @@ def init_db():
|
|||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# ── listings (매물 파이프라인) ──────────────────────────────────────
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS listings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
article_no TEXT UNIQUE, source TEXT NOT NULL, deal_type TEXT NOT NULL,
|
||||||
|
deposit INTEGER, monthly_rent INTEGER, sale_price INTEGER,
|
||||||
|
area_exclusive REAL, floor TEXT, complex_name TEXT, address TEXT,
|
||||||
|
dong TEXT, dong_code TEXT, url TEXT, posted_at TEXT, raw_json TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
notified_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS market_deals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
house_type TEXT, dong_code TEXT, dong TEXT, complex_name TEXT, area REAL,
|
||||||
|
deal_type TEXT, deposit INTEGER, monthly_rent INTEGER, sale_amount INTEGER,
|
||||||
|
deal_ym TEXT, floor TEXT, source TEXT DEFAULT 'molit',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# 플랜 원안은 table-level UNIQUE(...)였으나 SQLite는 UNIQUE 제약에서 NULL을 서로 다른 값으로
|
||||||
|
# 취급해 deposit/sale_amount(둘 중 하나는 항상 NULL)가 있는 행에서 dedup이 깨진다.
|
||||||
|
# (표현식은 table-level UNIQUE에 사용 불가 → CREATE UNIQUE INDEX + COALESCE로 대체.
|
||||||
|
# 실제 컬럼값은 그대로 NULL 유지 — downstream get_market_deals_for/listing_matcher 영향 없음.)
|
||||||
|
conn.execute("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_market_deals_dedup ON market_deals(
|
||||||
|
dong_code, COALESCE(complex_name,''), area, deal_ym, deal_type,
|
||||||
|
COALESCE(deposit,-1), COALESCE(sale_amount,-1), COALESCE(floor,'')
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS listing_matches (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
listing_id INTEGER UNIQUE REFERENCES listings(id) ON DELETE CASCADE,
|
||||||
|
category TEXT, passed INTEGER, match_score INTEGER,
|
||||||
|
jeonse_ratio REAL, safety_tier TEXT, price_ratio REAL, valuation_tier TEXT,
|
||||||
|
market_median INTEGER, sample_size INTEGER, budget_ok INTEGER,
|
||||||
|
regulation_flags TEXT, reasons TEXT, is_new INTEGER,
|
||||||
|
notified_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS listing_criteria (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
dongs TEXT NOT NULL DEFAULT '[]', deal_types TEXT NOT NULL DEFAULT '[]',
|
||||||
|
max_deposit INTEGER, max_sale_price INTEGER, min_area REAL,
|
||||||
|
house_types TEXT NOT NULL DEFAULT '[]', min_safety_tier TEXT,
|
||||||
|
notify_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
equity INTEGER, annual_income INTEGER,
|
||||||
|
is_homeless INTEGER NOT NULL DEFAULT 1, is_householder INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_first_home INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS listing_collect_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
new_count INTEGER, total_count INTEGER, naver_ok INTEGER,
|
||||||
|
error TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_md_lookup ON market_deals(dong_code, deal_type, area, deal_ym);")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_listing_dong ON listings(dong, deal_type);")
|
||||||
|
|
||||||
|
|
||||||
# ── 상태 자동 계산 ───────────────────────────────────────────────────────────
|
# ── 상태 자동 계산 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -841,3 +910,268 @@ def get_dashboard() -> Dict[str, Any]:
|
|||||||
"upcoming_schedules": schedules,
|
"upcoming_schedules": schedules,
|
||||||
"bookmarked": bookmarked_items,
|
"bookmarked": bookmarked_items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 매물 파이프라인 (listings/market_deals/listing_matches/listing_criteria) ──
|
||||||
|
|
||||||
|
_LISTING_CRITERIA_SEED = {
|
||||||
|
"dongs": ["신대방동", "대방동", "상도동", "봉천동", "대림동", "신길동"],
|
||||||
|
"deal_types": ["전세", "반전세", "매매"],
|
||||||
|
"max_deposit": 32000, "max_sale_price": None, "min_area": 40.0,
|
||||||
|
"house_types": ["아파트", "오피스텔", "빌라"], "min_safety_tier": None,
|
||||||
|
"notify_enabled": 1, "equity": None, "annual_income": None,
|
||||||
|
"is_homeless": 1, "is_householder": 0, "is_first_home": 0,
|
||||||
|
}
|
||||||
|
_LC_JSON_COLS = ("dongs", "deal_types", "house_types")
|
||||||
|
|
||||||
|
|
||||||
|
def get_listing_criteria() -> Dict[str, Any]:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM listing_criteria WHERE id=1").fetchone()
|
||||||
|
if row is None:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO listing_criteria
|
||||||
|
(id,dongs,deal_types,max_deposit,max_sale_price,min_area,house_types,
|
||||||
|
min_safety_tier,notify_enabled,equity,annual_income,is_homeless,is_householder,is_first_home)
|
||||||
|
VALUES (1,:dongs,:deal_types,:max_deposit,:max_sale_price,:min_area,:house_types,
|
||||||
|
:min_safety_tier,:notify_enabled,:equity,:annual_income,:is_homeless,:is_householder,:is_first_home)""",
|
||||||
|
{**_LISTING_CRITERIA_SEED,
|
||||||
|
"dongs": json.dumps(_LISTING_CRITERIA_SEED["dongs"], ensure_ascii=False),
|
||||||
|
"deal_types": json.dumps(_LISTING_CRITERIA_SEED["deal_types"], ensure_ascii=False),
|
||||||
|
"house_types": json.dumps(_LISTING_CRITERIA_SEED["house_types"], ensure_ascii=False)},
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM listing_criteria WHERE id=1").fetchone()
|
||||||
|
d = dict(row)
|
||||||
|
for c in _LC_JSON_COLS:
|
||||||
|
d[c] = json.loads(d[c] or "[]")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def update_listing_criteria(fields: Dict[str, Any]) -> None:
|
||||||
|
if not fields:
|
||||||
|
return
|
||||||
|
sets, vals = [], []
|
||||||
|
for k, v in fields.items():
|
||||||
|
if v is None and k not in ("max_deposit", "max_sale_price", "equity", "annual_income", "min_safety_tier"):
|
||||||
|
continue
|
||||||
|
sets.append(f"{k}=?")
|
||||||
|
vals.append(json.dumps(v, ensure_ascii=False) if k in _LC_JSON_COLS else v)
|
||||||
|
if not sets:
|
||||||
|
return
|
||||||
|
sets.append("updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')")
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO listing_criteria(id) VALUES(1)")
|
||||||
|
conn.execute(f"UPDATE listing_criteria SET {','.join(sets)} WHERE id=1", vals)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_listing(data: Dict[str, Any]) -> tuple:
|
||||||
|
cols = ("article_no", "source", "deal_type", "deposit", "monthly_rent", "sale_price",
|
||||||
|
"area_exclusive", "floor", "complex_name", "address", "dong", "dong_code",
|
||||||
|
"url", "posted_at", "raw_json")
|
||||||
|
d = {c: data.get(c) for c in cols}
|
||||||
|
with _conn() as conn:
|
||||||
|
exists = conn.execute("SELECT id FROM listings WHERE article_no=?", (d["article_no"],)).fetchone()
|
||||||
|
is_new = exists is None
|
||||||
|
conn.execute(f"""
|
||||||
|
INSERT INTO listings ({','.join(cols)}) VALUES ({','.join(':'+c for c in cols)})
|
||||||
|
ON CONFLICT(article_no) DO UPDATE SET
|
||||||
|
deposit=excluded.deposit, monthly_rent=excluded.monthly_rent, sale_price=excluded.sale_price,
|
||||||
|
floor=excluded.floor, complex_name=excluded.complex_name, address=excluded.address,
|
||||||
|
url=excluded.url, posted_at=excluded.posted_at, raw_json=excluded.raw_json,
|
||||||
|
dong_code=excluded.dong_code
|
||||||
|
""", d)
|
||||||
|
row = conn.execute("SELECT * FROM listings WHERE article_no=?", (d["article_no"],)).fetchone()
|
||||||
|
return dict(row), is_new
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_market_deal(data: Dict[str, Any]) -> None:
|
||||||
|
if data.get("area") is None:
|
||||||
|
# area 없는 실거래는 area BETWEEN 조회에서 절대 매칭 안 돼 median 계산에 무용하고,
|
||||||
|
# dedup 인덱스도 못 걸려(area가 NOT NULL 컬럼이 아니라 조건 매칭 불가) 무한 중복을 유발한다.
|
||||||
|
return
|
||||||
|
cols = ("house_type", "dong_code", "dong", "complex_name", "area", "deal_type",
|
||||||
|
"deposit", "monthly_rent", "sale_amount", "deal_ym", "floor", "source")
|
||||||
|
d = {c: data.get(c) for c in cols}
|
||||||
|
d.setdefault("source", "molit")
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(f"""INSERT OR IGNORE INTO market_deals ({','.join(cols)})
|
||||||
|
VALUES ({','.join(':'+c for c in cols)})""", d)
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_upsert_market_deals(rows: List[Dict[str, Any]]) -> int:
|
||||||
|
"""market_deals 다건 INSERT OR IGNORE (단일 connection). area=None은 skip(무용/bloat 방지).
|
||||||
|
저장시도 건수 반환. 기존 upsert_market_deal과 동일 dedup(ux_market_deals_dedup 인덱스)."""
|
||||||
|
cols = ("house_type", "dong_code", "dong", "complex_name", "area", "deal_type",
|
||||||
|
"deposit", "monthly_rent", "sale_amount", "deal_ym", "floor", "source")
|
||||||
|
payload = []
|
||||||
|
for data in rows:
|
||||||
|
if data.get("area") is None:
|
||||||
|
continue
|
||||||
|
d = {c: data.get(c) for c in cols}
|
||||||
|
d["source"] = data.get("source") or "molit"
|
||||||
|
payload.append(tuple(d[c] for c in cols))
|
||||||
|
if not payload:
|
||||||
|
return 0
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.executemany(
|
||||||
|
f"INSERT OR IGNORE INTO market_deals ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})",
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return len(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def get_market_deals_for(dong_code, complex_name, area, deal_type, months=6, conn=None) -> List[Dict[str, Any]]:
|
||||||
|
"""conn을 넘기면 재사용(open/close 안 함, 호출부가 소유) — run_listing_matching 단일 conn 배치용.
|
||||||
|
conn 없으면 기존대로 자체 _conn() 열고 닫음(safety-check 등 단건 호출 경로 불변)."""
|
||||||
|
own = conn is None
|
||||||
|
c = conn or _conn()
|
||||||
|
try:
|
||||||
|
def _query(cn):
|
||||||
|
rows = c.execute(
|
||||||
|
"""SELECT * FROM market_deals
|
||||||
|
WHERE dong_code=? AND deal_type=?
|
||||||
|
AND (complex_name=? OR ? IS NULL OR complex_name IS NULL)
|
||||||
|
AND area BETWEEN ? AND ?
|
||||||
|
AND deal_ym >= strftime('%Y%m', 'now', ?)""",
|
||||||
|
(dong_code, deal_type, cn, cn,
|
||||||
|
(area or 0) - 5, (area or 0) + 5, f"-{int(months)} months"),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
deals = _query(complex_name)
|
||||||
|
if len(deals) < _MARKET_SAMPLE_MIN and complex_name:
|
||||||
|
# 단지 표본이 부족하면 complex 제약을 풀고 같은 동/거래유형/면적±5/최근성으로
|
||||||
|
# 광역 재조회한다. 표본을 늘리기만 하므로 안전(잘못된 판정 유발 X).
|
||||||
|
deals = _query(None)
|
||||||
|
return deals
|
||||||
|
finally:
|
||||||
|
if own:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_listing_match(data: Dict[str, Any]) -> None:
|
||||||
|
cols = ("listing_id", "category", "passed", "match_score", "jeonse_ratio", "safety_tier",
|
||||||
|
"price_ratio", "valuation_tier", "market_median", "sample_size", "budget_ok",
|
||||||
|
"regulation_flags", "reasons", "is_new")
|
||||||
|
d = {c: data.get(c) for c in cols}
|
||||||
|
d["regulation_flags"] = json.dumps(data.get("regulation_flags") or [], ensure_ascii=False)
|
||||||
|
d["reasons"] = json.dumps(data.get("reasons") or [], ensure_ascii=False)
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(f"""
|
||||||
|
INSERT INTO listing_matches ({','.join(cols)}) VALUES ({','.join(':'+c for c in cols)})
|
||||||
|
ON CONFLICT(listing_id) DO UPDATE SET
|
||||||
|
passed=excluded.passed, match_score=excluded.match_score,
|
||||||
|
jeonse_ratio=excluded.jeonse_ratio, safety_tier=excluded.safety_tier,
|
||||||
|
price_ratio=excluded.price_ratio, valuation_tier=excluded.valuation_tier,
|
||||||
|
market_median=excluded.market_median, sample_size=excluded.sample_size,
|
||||||
|
budget_ok=excluded.budget_ok, regulation_flags=excluded.regulation_flags,
|
||||||
|
reasons=excluded.reasons, category=excluded.category
|
||||||
|
""", d)
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_upsert_listing_matches(recs: List[Dict[str, Any]], conn=None) -> None:
|
||||||
|
"""listing_matches 다건 upsert(단일 connection). conn 주면 재사용(호출부 with가 commit) —
|
||||||
|
run_listing_matching 269건×3conn 병목 개선(Task 2). ON CONFLICT 절은 upsert_listing_match와 동일
|
||||||
|
(notified_at 미포함=보존, 재알림 방지)."""
|
||||||
|
if not recs:
|
||||||
|
return
|
||||||
|
cols = ("listing_id", "category", "passed", "match_score", "jeonse_ratio", "safety_tier",
|
||||||
|
"price_ratio", "valuation_tier", "market_median", "sample_size", "budget_ok",
|
||||||
|
"regulation_flags", "reasons", "is_new")
|
||||||
|
own = conn is None
|
||||||
|
c = conn or _conn()
|
||||||
|
try:
|
||||||
|
for data in recs:
|
||||||
|
d = {col: data.get(col) for col in cols}
|
||||||
|
d["regulation_flags"] = json.dumps(data.get("regulation_flags") or [], ensure_ascii=False)
|
||||||
|
d["reasons"] = json.dumps(data.get("reasons") or [], ensure_ascii=False)
|
||||||
|
c.execute(f"""
|
||||||
|
INSERT INTO listing_matches ({','.join(cols)}) VALUES ({','.join(':'+col for col in cols)})
|
||||||
|
ON CONFLICT(listing_id) DO UPDATE SET
|
||||||
|
passed=excluded.passed, match_score=excluded.match_score,
|
||||||
|
jeonse_ratio=excluded.jeonse_ratio, safety_tier=excluded.safety_tier,
|
||||||
|
price_ratio=excluded.price_ratio, valuation_tier=excluded.valuation_tier,
|
||||||
|
market_median=excluded.market_median, sample_size=excluded.sample_size,
|
||||||
|
budget_ok=excluded.budget_ok, regulation_flags=excluded.regulation_flags,
|
||||||
|
reasons=excluded.reasons, category=excluded.category
|
||||||
|
""", d)
|
||||||
|
if own:
|
||||||
|
c.commit()
|
||||||
|
finally:
|
||||||
|
if own:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_listings(dong=None, deal_type=None, tier=None, matched_only=False, limit=50, offset=0) -> List[Dict[str, Any]]:
|
||||||
|
sql = ("SELECT l.*, m.safety_tier, m.valuation_tier, m.category, m.match_score, m.passed "
|
||||||
|
"FROM listings l LEFT JOIN listing_matches m ON m.listing_id=l.id WHERE 1=1")
|
||||||
|
p = []
|
||||||
|
if dong: sql += " AND l.dong=?"; p.append(dong)
|
||||||
|
if deal_type: sql += " AND l.deal_type=?"; p.append(deal_type)
|
||||||
|
if tier: sql += " AND (m.safety_tier=? OR m.valuation_tier=?)"; p += [tier, tier]
|
||||||
|
if matched_only: sql += " AND m.passed=1"
|
||||||
|
sql += " ORDER BY l.created_at DESC LIMIT ? OFFSET ?"; p += [limit, offset]
|
||||||
|
with _conn() as conn:
|
||||||
|
return [dict(r) for r in conn.execute(sql, p).fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_listing_matches() -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT m.*, l.complex_name, l.dong, l.deal_type, l.deposit, l.sale_price, l.area_exclusive, l.url "
|
||||||
|
"FROM listing_matches m JOIN listings l ON l.id=m.listing_id ORDER BY m.created_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["regulation_flags"] = json.loads(d.get("regulation_flags") or "[]")
|
||||||
|
d["reasons"] = json.loads(d.get("reasons") or "[]")
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_TIER_ORDER = {"위험": 0, "고가": 0, "주의": 1, "시세": 1, "안전": 2, "저평가": 2, "보류": -1}
|
||||||
|
|
||||||
|
|
||||||
|
def get_unnotified_listing_matches(min_tier=None) -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT m.*, l.complex_name, l.dong, l.deal_type, l.deposit, l.monthly_rent, "
|
||||||
|
"l.sale_price, l.area_exclusive, l.floor, l.url "
|
||||||
|
"FROM listing_matches m JOIN listings l ON l.id=m.listing_id "
|
||||||
|
"WHERE m.passed=1 AND m.notified_at IS NULL"
|
||||||
|
).fetchall()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["regulation_flags"] = json.loads(d.get("regulation_flags") or "[]")
|
||||||
|
d["reasons"] = json.loads(d.get("reasons") or "[]")
|
||||||
|
out.append(d)
|
||||||
|
if min_tier:
|
||||||
|
floor = _TIER_ORDER.get(min_tier, -1)
|
||||||
|
out = [d for d in out if _TIER_ORDER.get(d.get("safety_tier") or d.get("valuation_tier"), -1) >= floor]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def mark_listings_notified(match_ids: List[int]) -> None:
|
||||||
|
if not match_ids:
|
||||||
|
return
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.executemany(
|
||||||
|
"UPDATE listing_matches SET notified_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
|
||||||
|
[(i,) for i in match_ids],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_listing_collect_log(new_count, total_count, naver_ok, error=None) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO listing_collect_log (new_count,total_count,naver_ok,error) VALUES (?,?,?,?)",
|
||||||
|
(new_count, total_count, 1 if naver_ok else 0, error),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_listing_collect_log() -> Optional[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM listing_collect_log ORDER BY id DESC LIMIT 1").fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|||||||
67
realestate-lab/app/finance_rules.py
Normal file
67
realestate-lab/app/finance_rules.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""전세/매수 예산·규제 판정 (순수 함수, 외부 호출 없음)."""
|
||||||
|
from . import finance_rules_config as cfg
|
||||||
|
|
||||||
|
|
||||||
|
def region_regulation(dong: str) -> dict:
|
||||||
|
d = (dong or "").strip()
|
||||||
|
is_toheo = d in cfg.TOHEO_DONGS
|
||||||
|
is_regulated = d in cfg.REGULATED_DONGS or is_toheo
|
||||||
|
notes = []
|
||||||
|
if is_toheo:
|
||||||
|
notes.append("토지거래허가구역 — 매수 시 실거주 의무(2년)+허가 필요, 갭투자 금지")
|
||||||
|
if is_regulated:
|
||||||
|
notes.append("규제지역 — LTV 강화")
|
||||||
|
notes.append("토허/규제 여부는 서울시 최신 고시로 확인 필요")
|
||||||
|
return {"is_toheo": is_toheo, "is_regulated": is_regulated, "notes": " · ".join(notes)}
|
||||||
|
|
||||||
|
|
||||||
|
def _dsr_max_loan(annual_income: int) -> int:
|
||||||
|
"""DSR 한도 내 대출 원금 근사(만원). 원리금균등, 가정 금리·만기."""
|
||||||
|
if not annual_income or annual_income <= 0:
|
||||||
|
return 0
|
||||||
|
annual_pay_cap = annual_income * cfg.DSR_LIMIT_PCT / 100.0 # 연 상환 가능액
|
||||||
|
r = cfg.ASSUMED_RATE_PCT / 100.0 / 12
|
||||||
|
n = cfg.ASSUMED_TERM_YEARS * 12
|
||||||
|
monthly_cap = annual_pay_cap / 12.0
|
||||||
|
if r == 0:
|
||||||
|
return int(monthly_cap * n)
|
||||||
|
# PV = PMT * (1-(1+r)^-n)/r
|
||||||
|
pv = monthly_cap * (1 - (1 + r) ** (-n)) / r
|
||||||
|
return int(pv)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_jeonse_budget(equity: int, annual_income: int, jeonse_loan_ratio: float = None) -> dict:
|
||||||
|
equity = equity or 0
|
||||||
|
ratio = jeonse_loan_ratio if jeonse_loan_ratio is not None else cfg.JEONSE_LOAN_RATIO
|
||||||
|
# 전세대출 한도 = min(보증기관 절대상한, DSR 한도). 보증금 비례는 매물별로 matcher에서 재확인.
|
||||||
|
loan_limit = min(cfg.JEONSE_LOAN_CAP, _dsr_max_loan(annual_income) or cfg.JEONSE_LOAN_CAP)
|
||||||
|
max_deposit = equity + loan_limit
|
||||||
|
return {
|
||||||
|
"loan_limit": loan_limit,
|
||||||
|
"max_deposit": max_deposit,
|
||||||
|
"notes": f"전세대출 한도≈{loan_limit}만(보증기관·DSR 근사, 보증금 대비 {int(ratio*100)}% 이내). {cfg.DISCLAIMER}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_purchase_budget(equity: int, annual_income: int, region_flags: dict,
|
||||||
|
is_first_home: bool, is_homeless: bool) -> dict:
|
||||||
|
equity = equity or 0
|
||||||
|
reg = "regulated" if (region_flags or {}).get("is_regulated") else "normal"
|
||||||
|
first = "first" if is_first_home else "normal"
|
||||||
|
ltv_pct = cfg.LTV_TABLE[(reg, first)]
|
||||||
|
# 대출가능액 = min(수도권 한도, DSR 한도). LTV는 매매가에 따라 matcher에서 상한 재확인.
|
||||||
|
dsr_cap = _dsr_max_loan(annual_income)
|
||||||
|
loan_cap = min(cfg.SUDOGWON_MORTGAGE_CAP, dsr_cap) if dsr_cap else cfg.SUDOGWON_MORTGAGE_CAP
|
||||||
|
max_price = equity + loan_cap
|
||||||
|
flags = []
|
||||||
|
if (region_flags or {}).get("is_toheo"):
|
||||||
|
flags.append("토허")
|
||||||
|
if (region_flags or {}).get("is_regulated"):
|
||||||
|
flags.append("규제지역")
|
||||||
|
return {
|
||||||
|
"ltv_pct": ltv_pct,
|
||||||
|
"loan_cap": loan_cap,
|
||||||
|
"max_price": max_price,
|
||||||
|
"dsr_note": f"DSR {cfg.DSR_LIMIT_PCT}% 한도 근사(금리{cfg.ASSUMED_RATE_PCT}%·{cfg.ASSUMED_TERM_YEARS}년)",
|
||||||
|
"regulation_flags": flags,
|
||||||
|
}
|
||||||
35
realestate-lab/app/finance_rules_config.py
Normal file
35
realestate-lab/app/finance_rules_config.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""정책 파라미터 (외부 호출 없음). ⚠️ 정부 정책 수시 변동 — 계약·대출 전 은행/서울시 고시로 최종 확인.
|
||||||
|
기준: 2025 6·27 대책(수도권 주담대 6억 한도) 등. 실제 집행 전 최신값으로 갱신."""
|
||||||
|
|
||||||
|
# 수도권 주택담보대출 총액 한도 (만원). 6·27 대책: 6억.
|
||||||
|
SUDOGWON_MORTGAGE_CAP = 60000
|
||||||
|
|
||||||
|
# LTV(%) — (규제지역 여부, 생애최초 여부) → 한도. 무주택 기준.
|
||||||
|
LTV_TABLE = {
|
||||||
|
("regulated", "first"): 70,
|
||||||
|
("regulated", "normal"): 50,
|
||||||
|
("normal", "first"): 80,
|
||||||
|
("normal", "normal"): 70,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 스트레스 DSR 한도(%) — 연소득 대비 연원리금.
|
||||||
|
DSR_LIMIT_PCT = 40
|
||||||
|
# 대출 산정용 가정 금리(연%)·만기(년) — 원리금균등 근사.
|
||||||
|
ASSUMED_RATE_PCT = 4.5
|
||||||
|
ASSUMED_TERM_YEARS = 30
|
||||||
|
|
||||||
|
# 전세대출: 보증기관 보증비율(보증금 대비) 상한 근사.
|
||||||
|
JEONSE_LOAN_RATIO = 0.80
|
||||||
|
# 전세대출 절대 상한(만원) — 보증기관 한도 근사.
|
||||||
|
JEONSE_LOAN_CAP = 44000
|
||||||
|
|
||||||
|
# 토지거래허가구역(구·동 단위, 서울시 고시 기준, 수동 갱신). 소수만.
|
||||||
|
TOHEO_DONGS = {
|
||||||
|
"대치동", "삼성동", "청담동", "잠실동", # 강남·송파 국제교류복합지구권
|
||||||
|
"압구정동", "여의도동", "목동", "성수동", # 재건축 토허
|
||||||
|
"이촌동", "한남동", # 용산
|
||||||
|
}
|
||||||
|
# 규제지역(투기과열/조정) — 매수 LTV 강화 대상.
|
||||||
|
REGULATED_DONGS = set(TOHEO_DONGS) | {"반포동", "서초동"}
|
||||||
|
|
||||||
|
DISCLAIMER = "정책(LTV·DSR·수도권 한도·토허)은 수시 변동 — 계약·대출 전 은행/중개사/서울시 고시로 최종 확인."
|
||||||
81
realestate-lab/app/internal_router.py
Normal file
81
realestate-lab/app/internal_router.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""naver-fetch 워커 내부 계약: targets 조회 + listings ingest."""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from fastapi import APIRouter, Depends, BackgroundTasks
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .auth import verify_internal_key
|
||||||
|
from .db import get_listing_criteria, upsert_listing
|
||||||
|
from .lawd_codes import naver_cortar, lawd_code
|
||||||
|
from .listing_collector import _parse_naver_article
|
||||||
|
from .listing_matcher import run_listing_matching
|
||||||
|
from .notifier import notify_new_listings
|
||||||
|
from .pipeline_lock import match_notify_lock
|
||||||
|
|
||||||
|
logger = logging.getLogger("realestate-lab")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
NAVER_PAGE_LIMIT = int(os.getenv("NAVER_PAGE_LIMIT", "2"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/internal/realestate/targets", dependencies=[Depends(verify_internal_key)])
|
||||||
|
def listing_targets():
|
||||||
|
crit = get_listing_criteria()
|
||||||
|
dongs = []
|
||||||
|
for d in crit.get("dongs", []):
|
||||||
|
code = naver_cortar(d)
|
||||||
|
if not code:
|
||||||
|
logger.warning("naver cortarNo 매핑 없음 — 대상 제외: %s", d)
|
||||||
|
continue
|
||||||
|
dongs.append({"dong": d, "cortar_no": code})
|
||||||
|
return {"dongs": dongs, "deal_types": crit.get("deal_types", []),
|
||||||
|
"page_limit": NAVER_PAGE_LIMIT}
|
||||||
|
|
||||||
|
|
||||||
|
class _Batch(BaseModel):
|
||||||
|
dong: str
|
||||||
|
articles: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ListingsIngest(BaseModel):
|
||||||
|
fetched_at: str | None = None
|
||||||
|
batches: List[_Batch] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/internal/realestate/listings-ingest",
|
||||||
|
dependencies=[Depends(verify_internal_key)])
|
||||||
|
def listings_ingest(body: ListingsIngest, background_tasks: BackgroundTasks):
|
||||||
|
received = new = 0
|
||||||
|
for batch in body.batches:
|
||||||
|
for raw in batch.articles:
|
||||||
|
try:
|
||||||
|
d = _parse_naver_article(raw)
|
||||||
|
if not d.get("article_no"):
|
||||||
|
continue
|
||||||
|
d["dong"] = batch.dong
|
||||||
|
# 네이버 article에 cortarNo가 없는 경우가 실사용에서 확인됨 → dong명에서
|
||||||
|
# 5자리 시군구코드(lawd_code)를 유도(market_deals는 이 5자리를 쓴다).
|
||||||
|
d["dong_code"] = d.get("dong_code") or lawd_code(batch.dong)
|
||||||
|
_, is_new = upsert_listing(d)
|
||||||
|
received += 1
|
||||||
|
if is_new:
|
||||||
|
new += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("네이버 ingest 파싱 실패: %s", e)
|
||||||
|
if received:
|
||||||
|
background_tasks.add_task(_match_and_notify)
|
||||||
|
return {"received": received, "new": new, "queued": bool(received)}
|
||||||
|
|
||||||
|
|
||||||
|
def _match_and_notify():
|
||||||
|
"""ingest 후처리(백그라운드): 매칭+알림을 공유 락으로 직렬화. 타이밍 로그로 병목 관측."""
|
||||||
|
with match_notify_lock:
|
||||||
|
t0 = time.time()
|
||||||
|
run_listing_matching()
|
||||||
|
t1 = time.time()
|
||||||
|
noti = notify_new_listings()
|
||||||
|
t2 = time.time()
|
||||||
|
sent = (noti or {}).get("sent") if isinstance(noti, dict) else noti
|
||||||
|
logger.info("ingest 후처리: match=%.1fs notify=%.1fs sent=%s", t1 - t0, t2 - t1, sent)
|
||||||
40
realestate-lab/app/lawd_codes.py
Normal file
40
realestate-lab/app/lawd_codes.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""대상 동명 → 법정동코드(시군구 5자리, LAWD_CD). 국토부 실거래가 API 파라미터용.
|
||||||
|
동작구/관악구/영등포구 중심(스펙 초기 대상 동). 신규 동 추가 시 여기에.
|
||||||
|
출처: 행정표준코드관리시스템 시군구 코드."""
|
||||||
|
|
||||||
|
# 시군구(구) 단위 5자리 — 실거래가 API는 LAWD_CD=시군구코드를 받는다.
|
||||||
|
_DISTRICT_CODE = {
|
||||||
|
"동작구": "11590",
|
||||||
|
"관악구": "11620",
|
||||||
|
"영등포구": "11560",
|
||||||
|
"서초구": "11650",
|
||||||
|
"강남구": "11680",
|
||||||
|
}
|
||||||
|
# 동 → 소속 구
|
||||||
|
_DONG_TO_DISTRICT = {
|
||||||
|
"신대방동": "동작구", "대방동": "동작구", "상도동": "동작구", "노량진동": "동작구", "사당동": "동작구",
|
||||||
|
"봉천동": "관악구", "신림동": "관악구",
|
||||||
|
"신길동": "영등포구", "대림동": "영등포구", "영등포동": "영등포구",
|
||||||
|
}
|
||||||
|
|
||||||
|
LAWD_CODES = {dong: _DISTRICT_CODE[d] for dong, d in _DONG_TO_DISTRICT.items() if d in _DISTRICT_CODE}
|
||||||
|
|
||||||
|
|
||||||
|
def lawd_code(dong: str) -> str | None:
|
||||||
|
return LAWD_CODES.get((dong or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
# 네이버부동산 cortarNo = 10자리 법정동코드 (LAWD_CODES 5자리 시군구와 별개).
|
||||||
|
# 출처: 행정표준코드관리시스템 법정동코드. 대상 동 추가 시 여기에.
|
||||||
|
NAVER_CORTAR = {
|
||||||
|
"신대방동": "1159010100",
|
||||||
|
"대방동": "1159010200",
|
||||||
|
"상도동": "1159010600",
|
||||||
|
"봉천동": "1162010100",
|
||||||
|
"대림동": "1156013600",
|
||||||
|
"신길동": "1156013200",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def naver_cortar(dong: str) -> str | None:
|
||||||
|
return NAVER_CORTAR.get((dong or "").strip())
|
||||||
181
realestate-lab/app/listing_collector.py
Normal file
181
realestate-lab/app/listing_collector.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""매물 수집: 국토부 실거래가(합법, market_deals) + 네이버부동산 호가(회색·폴백, listings)."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from .lawd_codes import LAWD_CODES
|
||||||
|
from .db import (get_listing_criteria, upsert_listing,
|
||||||
|
bulk_upsert_market_deals, save_listing_collect_log)
|
||||||
|
|
||||||
|
logger = logging.getLogger("realestate-lab")
|
||||||
|
|
||||||
|
MOLIT_KEY = os.getenv("DATA_GO_KR_API_KEY", "")
|
||||||
|
NAVER_BASE = "https://new.land.naver.com/api"
|
||||||
|
# MOLIT operation ID (스펙 §3, 라이브 확인 필요). house_type → (rent_op, trade_op)
|
||||||
|
MOLIT_OPS = {
|
||||||
|
"아파트": ("getRTMSDataSvcAptRent", "getRTMSDataSvcAptTradeDev"),
|
||||||
|
"오피스텔": ("getRTMSDataSvcOffiRent", "getRTMSDataSvcOffiTrade"),
|
||||||
|
"연립다세대": ("getRTMSDataSvcRHRent", "getRTMSDataSvcRHTrade"),
|
||||||
|
}
|
||||||
|
_HOUSE_TYPE_ALIAS = {"빌라": "연립다세대"}
|
||||||
|
|
||||||
|
|
||||||
|
def _won_to_manwon(s) -> int | None:
|
||||||
|
"""'43,000' 또는 '2억 9,000' → 만원 정수."""
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
t = str(s).replace(",", "").strip()
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
m = re.match(r"(?:(\d+)억)?\s*(\d+)?", t)
|
||||||
|
if m and (m.group(1) or m.group(2)):
|
||||||
|
eok = int(m.group(1)) if m.group(1) else 0
|
||||||
|
man = int(m.group(2)) if m.group(2) else 0
|
||||||
|
return eok * 10000 + man
|
||||||
|
try:
|
||||||
|
return int(float(t))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_molit_rent(raw: dict, house_type: str) -> dict:
|
||||||
|
deposit = _won_to_manwon(raw.get("deposit"))
|
||||||
|
rent = _won_to_manwon(raw.get("monthlyRent"))
|
||||||
|
return {"house_type": house_type, "complex_name": (raw.get("aptNm") or raw.get("offiNm") or "").strip(),
|
||||||
|
"area": float(raw.get("excluUseAr") or 0) or None, "deal_type": "전세" if not rent else "월세",
|
||||||
|
"deposit": deposit, "monthly_rent": rent, "sale_amount": None,
|
||||||
|
"deal_ym": f"{raw.get('dealYear','')}{str(raw.get('dealMonth','')).zfill(2)}",
|
||||||
|
"floor": str(raw.get("floor") or ""), "source": "molit"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_molit_trade(raw: dict, house_type: str) -> dict:
|
||||||
|
return {"house_type": house_type, "complex_name": (raw.get("aptNm") or raw.get("offiNm") or "").strip(),
|
||||||
|
"area": float(raw.get("excluUseAr") or 0) or None, "deal_type": "매매",
|
||||||
|
"deposit": None, "monthly_rent": None,
|
||||||
|
"sale_amount": _won_to_manwon(raw.get("dealAmount")),
|
||||||
|
"deal_ym": f"{raw.get('dealYear','')}{str(raw.get('dealMonth','')).zfill(2)}",
|
||||||
|
"floor": str(raw.get("floor") or ""), "source": "molit"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_naver_article(raw: dict) -> dict:
|
||||||
|
dt = raw.get("tradeTypeName") or ""
|
||||||
|
prc = _won_to_manwon(raw.get("dealOrWarrantPrc"))
|
||||||
|
rent = _won_to_manwon(raw.get("rentPrc"))
|
||||||
|
is_sale = dt == "매매"
|
||||||
|
return {"article_no": str(raw.get("articleNo") or ""), "source": "naver", "deal_type": dt,
|
||||||
|
"deposit": None if is_sale else prc, "monthly_rent": rent,
|
||||||
|
"sale_price": prc if is_sale else None,
|
||||||
|
"area_exclusive": float(raw.get("area2") or raw.get("areaName") or 0) or None,
|
||||||
|
"floor": raw.get("floorInfo"), "complex_name": raw.get("articleName") or raw.get("buildingName"),
|
||||||
|
"dong_code": (raw.get("cortarNo") or "")[:5] or None,
|
||||||
|
"url": f"https://new.land.naver.com/houses?articleNo={raw.get('articleNo')}",
|
||||||
|
"raw_json": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _molit_call(op: str, lawd: str, ym: str):
|
||||||
|
"""국토부 실거래 단건 호출 (재시도). Returns (items, diag).
|
||||||
|
diag=None on success, else 짧은 진단 문자열(http=401 / json_err / req_err=...) — 관측용."""
|
||||||
|
if not MOLIT_KEY:
|
||||||
|
return [], "no_key"
|
||||||
|
url = f"https://apis.data.go.kr/1613000/RTMSDataSvc{op[len('getRTMSDataSvc'):]}/{op}"
|
||||||
|
last_diag = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, params={"serviceKey": MOLIT_KEY, "LAWD_CD": lawd,
|
||||||
|
"DEAL_YMD": ym, "numOfRows": 200, "_type": "json"}, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
body = resp.json()
|
||||||
|
items = (((body.get("response") or {}).get("body") or {}).get("items") or {}).get("item") or []
|
||||||
|
return (items if isinstance(items, list) else [items]), None
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
code = getattr(getattr(e, "response", None), "status_code", "?")
|
||||||
|
last_diag = f"http={code}"
|
||||||
|
except requests.RequestException as e:
|
||||||
|
last_diag = f"req_err={type(e).__name__}"
|
||||||
|
except ValueError:
|
||||||
|
# 200이지만 JSON 아님(XML 오류 응답 등) — 재시도 무의미
|
||||||
|
last_diag = "json_err"
|
||||||
|
break
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
logger.error("MOLIT %s 실패: %s", op, last_diag)
|
||||||
|
return [], last_diag
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_molit_all():
|
||||||
|
"""대상 동 시군구코드 × 유형 × 최근 2개월 전월세·매매 실거래 수집→market_deals.
|
||||||
|
Returns (saved_count, diag). diag = 'calls=N saved=M [err=<첫에러>]' 관측용."""
|
||||||
|
all_deals = []
|
||||||
|
calls = 0
|
||||||
|
first_err = None
|
||||||
|
lawds = set(LAWD_CODES.values())
|
||||||
|
today = date.today()
|
||||||
|
yms = [today.strftime("%Y%m"),
|
||||||
|
(today.replace(day=1) - __import__("datetime").timedelta(days=1)).strftime("%Y%m")]
|
||||||
|
for lawd in lawds:
|
||||||
|
for htype, (rent_op, trade_op) in MOLIT_OPS.items():
|
||||||
|
for ym in yms:
|
||||||
|
for op, parser in ((rent_op, _parse_molit_rent), (trade_op, _parse_molit_trade)):
|
||||||
|
items, diag = _molit_call(op, lawd, ym)
|
||||||
|
calls += 1
|
||||||
|
if diag and first_err is None:
|
||||||
|
first_err = diag
|
||||||
|
for raw in items:
|
||||||
|
try:
|
||||||
|
d = parser(raw, htype)
|
||||||
|
d["dong_code"] = lawd
|
||||||
|
d["dong"] = (raw.get("umdNm") or "").strip() or None
|
||||||
|
all_deals.append(d)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("MOLIT 파싱 실패: %s", e)
|
||||||
|
time.sleep(0.3)
|
||||||
|
saved = bulk_upsert_market_deals(all_deals)
|
||||||
|
diag = f"calls={calls} saved={saved}" + (f" err={first_err}" if first_err else "")
|
||||||
|
return saved, diag
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_naver_all() -> tuple:
|
||||||
|
"""대상 동 네이버 호가 매물 수집→listings. (new_count, total_count) 반환. 예외는 상위로 전파."""
|
||||||
|
new_count = total = 0
|
||||||
|
crit = get_listing_criteria()
|
||||||
|
for dong in crit.get("dongs", []):
|
||||||
|
code = LAWD_CODES.get(dong)
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
# 저빈도: 동당 1회, UA/Referer 현실값. 응답 articleList 순회.
|
||||||
|
resp = requests.get(f"{NAVER_BASE}/articles",
|
||||||
|
params={"cortarNo": code, "order": "dateDesc", "page": 1},
|
||||||
|
headers={"User-Agent": "Mozilla/5.0", "Referer": "https://new.land.naver.com/"},
|
||||||
|
timeout=20)
|
||||||
|
resp.raise_for_status()
|
||||||
|
for raw in (resp.json().get("articleList") or []):
|
||||||
|
try:
|
||||||
|
d = _parse_naver_article(raw)
|
||||||
|
if not d["article_no"]:
|
||||||
|
continue
|
||||||
|
d["dong"] = dong
|
||||||
|
_, is_new = upsert_listing(d)
|
||||||
|
total += 1
|
||||||
|
if is_new:
|
||||||
|
new_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("네이버 파싱 실패: %s", e)
|
||||||
|
time.sleep(1.5)
|
||||||
|
return new_count, total
|
||||||
|
|
||||||
|
|
||||||
|
def collect_listings() -> dict:
|
||||||
|
"""MOLIT 실거래(합법 baseline)만 수집. 네이버 호가는 naver-fetch 워커가
|
||||||
|
/api/internal/realestate/listings-ingest로 push (spec 2026-07-09-naver-listing-worker)."""
|
||||||
|
molit_diag = None
|
||||||
|
try:
|
||||||
|
_, molit_diag = _fetch_molit_all()
|
||||||
|
except Exception as e:
|
||||||
|
molit_diag = f"exc={type(e).__name__}:{str(e)[:80]}"
|
||||||
|
logger.error("MOLIT 수집 오류(안전마진 baseline): %s", e)
|
||||||
|
diag = f"molit[{molit_diag}] naver[worker]"
|
||||||
|
save_listing_collect_log(0, 0, None, error=diag)
|
||||||
|
return {"new_count": 0, "total_count": 0, "naver_ok": None, "diag": diag}
|
||||||
128
realestate-lab/app/listing_matcher.py
Normal file
128
realestate-lab/app/listing_matcher.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""매물 조건 매칭 + 임차 안전마진(전세가율)·매매 적정성(호가율) 판정."""
|
||||||
|
import logging
|
||||||
|
import statistics
|
||||||
|
|
||||||
|
from .db import (get_listing_criteria, get_market_deals_for, bulk_upsert_listing_matches,
|
||||||
|
_conn)
|
||||||
|
from . import finance_rules
|
||||||
|
from .lawd_codes import lawd_code
|
||||||
|
|
||||||
|
logger = logging.getLogger("realestate-lab")
|
||||||
|
|
||||||
|
# 경계 상수(조정 가능)
|
||||||
|
JEONSE_SAFE, JEONSE_WARN = 0.70, 0.80
|
||||||
|
VALUATION_LOW, VALUATION_HIGH = 0.97, 1.05
|
||||||
|
MIN_SAMPLE = 3
|
||||||
|
DISCLAIMER = "등기부 선순위 근저당은 자동 확인 불가 — 계약 전 인터넷등기소 열람 필수."
|
||||||
|
|
||||||
|
|
||||||
|
def compute_safety(deposit, deal_type, deals) -> dict:
|
||||||
|
vals = [d["deposit"] for d in deals if d.get("deposit")]
|
||||||
|
if len(vals) < MIN_SAMPLE:
|
||||||
|
return {"jeonse_ratio": None, "tier": "보류", "median": None, "sample": len(vals)}
|
||||||
|
median = statistics.median(vals)
|
||||||
|
ratio = (deposit or 0) / median if median else None
|
||||||
|
if ratio is None:
|
||||||
|
tier = "보류"
|
||||||
|
elif ratio <= JEONSE_SAFE:
|
||||||
|
tier = "안전"
|
||||||
|
elif ratio <= JEONSE_WARN:
|
||||||
|
tier = "주의"
|
||||||
|
else:
|
||||||
|
tier = "위험"
|
||||||
|
return {"jeonse_ratio": round(ratio, 3) if ratio else None,
|
||||||
|
"tier": tier, "median": int(median), "sample": len(vals)}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_valuation(sale_price, deals) -> dict:
|
||||||
|
vals = [d["sale_amount"] for d in deals if d.get("sale_amount")]
|
||||||
|
if len(vals) < MIN_SAMPLE:
|
||||||
|
return {"price_ratio": None, "tier": "보류", "median": None, "sample": len(vals)}
|
||||||
|
median = statistics.median(vals)
|
||||||
|
ratio = (sale_price or 0) / median if median else None
|
||||||
|
if ratio is None:
|
||||||
|
tier = "보류"
|
||||||
|
elif ratio <= VALUATION_LOW:
|
||||||
|
tier = "저평가"
|
||||||
|
elif ratio <= VALUATION_HIGH:
|
||||||
|
tier = "시세"
|
||||||
|
else:
|
||||||
|
tier = "고가"
|
||||||
|
return {"price_ratio": round(ratio, 3) if ratio else None,
|
||||||
|
"tier": tier, "median": int(median), "sample": len(vals)}
|
||||||
|
|
||||||
|
|
||||||
|
def match_listing(criteria, listing, budget) -> dict:
|
||||||
|
reasons, passed = [], True
|
||||||
|
if criteria.get("dongs") and listing.get("dong") not in criteria["dongs"]:
|
||||||
|
passed = False; reasons.append("동 불일치")
|
||||||
|
if criteria.get("deal_types") and listing.get("deal_type") not in criteria["deal_types"]:
|
||||||
|
passed = False; reasons.append("거래유형 불일치")
|
||||||
|
if criteria.get("min_area") and (listing.get("area_exclusive") or 0) < criteria["min_area"]:
|
||||||
|
passed = False; reasons.append("면적 미달")
|
||||||
|
if listing.get("deal_type") == "매매":
|
||||||
|
cap = criteria.get("max_sale_price") or (budget or {}).get("max_price")
|
||||||
|
if cap and (listing.get("sale_price") or 0) > cap:
|
||||||
|
passed = False; reasons.append("매수가능 상한 초과")
|
||||||
|
else: # 임차
|
||||||
|
cap = criteria.get("max_deposit") or (budget or {}).get("max_deposit")
|
||||||
|
if cap and (listing.get("deposit") or 0) > cap:
|
||||||
|
passed = False; reasons.append("보증금 상한 초과")
|
||||||
|
score = 100 if passed else 0
|
||||||
|
if passed:
|
||||||
|
reasons.append("조건 통과")
|
||||||
|
return {"passed": passed, "score": score, "reasons": reasons}
|
||||||
|
|
||||||
|
|
||||||
|
def run_listing_matching() -> None:
|
||||||
|
"""미평가 포함 전체 매물 재평가 → listing_matches upsert (청약 run_matching 대칭).
|
||||||
|
listings 조회 + 매물별 market_deals 조회 + 최종 upsert를 단일 connection으로 처리
|
||||||
|
(Task 2 성능개선: 이전엔 매물마다 get_market_deals_for/upsert_listing_match가 각각
|
||||||
|
자체 _conn()을 열어 269건×~3conn이 발생했다)."""
|
||||||
|
criteria = get_listing_criteria()
|
||||||
|
equity = criteria.get("equity") or 0
|
||||||
|
income = criteria.get("annual_income") or 0
|
||||||
|
jeonse_budget = finance_rules.estimate_jeonse_budget(equity, income)
|
||||||
|
with _conn() as conn:
|
||||||
|
listings = [dict(r) for r in conn.execute(
|
||||||
|
"SELECT * FROM listings ORDER BY created_at DESC LIMIT 1000"
|
||||||
|
).fetchall()]
|
||||||
|
recs = []
|
||||||
|
for l in listings:
|
||||||
|
is_rent = l["deal_type"] != "매매"
|
||||||
|
reg = finance_rules.region_regulation(l.get("dong"))
|
||||||
|
if is_rent:
|
||||||
|
budget = jeonse_budget
|
||||||
|
else:
|
||||||
|
budget = finance_rules.estimate_purchase_budget(
|
||||||
|
equity, income, reg, criteria.get("is_first_home"), criteria.get("is_homeless"))
|
||||||
|
m = match_listing(criteria, l, budget)
|
||||||
|
# 네이버 cortarNo 부재 등으로 기존 저장된 listing.dong_code가 None일 수 있음 —
|
||||||
|
# dong명에서 5자리 lawd_code를 유도해 즉시 판정(보류 고착 방지).
|
||||||
|
dc = l.get("dong_code") or lawd_code(l.get("dong"))
|
||||||
|
deals = get_market_deals_for(dc, l.get("complex_name"),
|
||||||
|
l.get("area_exclusive"), "전세" if is_rent else "매매",
|
||||||
|
conn=conn)
|
||||||
|
rec = {"listing_id": l["id"], "category": "임차" if is_rent else "매매",
|
||||||
|
"passed": 1 if m["passed"] else 0, "match_score": m["score"],
|
||||||
|
"sample_size": 0, "reasons": m["reasons"], "is_new": 1,
|
||||||
|
"regulation_flags": reg_flags(reg)}
|
||||||
|
if is_rent:
|
||||||
|
s = compute_safety(l.get("deposit"), l["deal_type"], deals)
|
||||||
|
rec.update({"jeonse_ratio": s["jeonse_ratio"], "safety_tier": s["tier"],
|
||||||
|
"market_median": s["median"], "sample_size": s["sample"]})
|
||||||
|
else:
|
||||||
|
v = compute_valuation(l.get("sale_price"), deals)
|
||||||
|
rec.update({"price_ratio": v["price_ratio"], "valuation_tier": v["tier"],
|
||||||
|
"market_median": v["median"], "sample_size": v["sample"],
|
||||||
|
"budget_ok": 1 if (l.get("sale_price") or 0) <= (budget.get("max_price") or 0) else 0})
|
||||||
|
recs.append(rec)
|
||||||
|
bulk_upsert_listing_matches(recs, conn=conn)
|
||||||
|
logger.info("매물 매칭 완료(%d건)", len(recs))
|
||||||
|
|
||||||
|
|
||||||
|
def reg_flags(reg: dict) -> list:
|
||||||
|
f = []
|
||||||
|
if reg.get("is_toheo"): f.append("토허")
|
||||||
|
if reg.get("is_regulated"): f.append("규제지역")
|
||||||
|
return f
|
||||||
@@ -15,11 +15,22 @@ from .db import (
|
|||||||
get_profile, upsert_profile, get_matches, mark_match_read,
|
get_profile, upsert_profile, get_matches, mark_match_read,
|
||||||
get_last_collect_log, get_dashboard,
|
get_last_collect_log, get_dashboard,
|
||||||
delete_old_completed_announcements,
|
delete_old_completed_announcements,
|
||||||
|
get_listing_criteria, update_listing_criteria, get_listings, get_listing_matches,
|
||||||
|
get_last_listing_collect_log, get_market_deals_for,
|
||||||
)
|
)
|
||||||
from .collector import collect_all
|
from .collector import collect_all
|
||||||
from .matcher import run_matching
|
from .matcher import run_matching
|
||||||
from .notifier import notify_new_matches
|
from .notifier import notify_new_matches, notify_new_listings
|
||||||
from .models import AnnouncementCreate, AnnouncementUpdate, ProfileUpdate
|
from .listing_collector import collect_listings
|
||||||
|
from .listing_matcher import run_listing_matching, compute_safety, compute_valuation
|
||||||
|
from .pipeline_lock import match_notify_lock
|
||||||
|
from . import finance_rules
|
||||||
|
from .finance_rules_config import DISCLAIMER
|
||||||
|
from .models import (
|
||||||
|
AnnouncementCreate, AnnouncementUpdate, ProfileUpdate,
|
||||||
|
ListingCriteriaUpdate, SafetyCheckRequest, BudgetRequest,
|
||||||
|
)
|
||||||
|
from .internal_router import router as internal_router
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||||
logger = logging.getLogger("realestate-lab")
|
logger = logging.getLogger("realestate-lab")
|
||||||
@@ -56,12 +67,32 @@ def scheduled_status_update():
|
|||||||
logger.info("상태 갱신 + 재매칭 완료")
|
logger.info("상태 갱신 + 재매칭 완료")
|
||||||
|
|
||||||
|
|
||||||
|
_listing_collect_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_listing_pipeline():
|
||||||
|
if not _listing_collect_lock.acquire(blocking=False):
|
||||||
|
logger.info("매물 수집 이미 진행 중 — 건너뜀"); return
|
||||||
|
try:
|
||||||
|
collect_listings()
|
||||||
|
with match_notify_lock:
|
||||||
|
run_listing_matching()
|
||||||
|
notify_new_listings()
|
||||||
|
finally:
|
||||||
|
_listing_collect_lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
def scheduled_listing_collect():
|
||||||
|
logger.info("매물 스케줄 수집 시작"); _run_listing_pipeline()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
# 09:00 cron 스태거링 — agent-office 09:00/05/10 이후 (CHECK_POINT FU-A)
|
# 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_collect, "cron", hour=9, minute=15, id="collect")
|
||||||
scheduler.add_job(scheduled_status_update, "cron", hour=0, minute=0, id="status_update")
|
scheduler.add_job(scheduled_status_update, "cron", hour=0, minute=0, id="status_update")
|
||||||
|
scheduler.add_job(scheduled_listing_collect, "cron", hour="8,11,14,17,20", minute=40, id="listing_collect")
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("realestate-lab 시작")
|
logger.info("realestate-lab 시작")
|
||||||
yield
|
yield
|
||||||
@@ -70,6 +101,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
install_access_log(app)
|
install_access_log(app)
|
||||||
|
app.include_router(internal_router)
|
||||||
|
|
||||||
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -217,3 +249,78 @@ def api_match_read(match_id: int):
|
|||||||
@app.get("/api/realestate/dashboard")
|
@app.get("/api/realestate/dashboard")
|
||||||
def api_dashboard():
|
def api_dashboard():
|
||||||
return get_dashboard()
|
return get_dashboard()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 매물 API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/realestate/listings")
|
||||||
|
def api_listings(dong: str = None, deal_type: str = None, tier: str = None,
|
||||||
|
matched_only: bool = False, page: int = 1, size: int = 50):
|
||||||
|
return {"listings": get_listings(dong, deal_type, tier, matched_only, size, (page - 1) * size)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/realestate/listings/collect")
|
||||||
|
def api_listings_collect(background_tasks: BackgroundTasks):
|
||||||
|
background_tasks.add_task(_run_listing_pipeline)
|
||||||
|
return {"ok": True, "message": "매물 수집 시작됨"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/realestate/listings/collect/status")
|
||||||
|
def api_listings_collect_status():
|
||||||
|
return get_last_listing_collect_log() or {"status": "never_run"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/realestate/listings/criteria")
|
||||||
|
def api_get_listing_criteria():
|
||||||
|
return get_listing_criteria()
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/realestate/listings/criteria")
|
||||||
|
def api_put_listing_criteria(req: ListingCriteriaUpdate):
|
||||||
|
update_listing_criteria(req.model_dump(exclude_none=True))
|
||||||
|
return get_listing_criteria()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/realestate/listings/matches")
|
||||||
|
def api_listing_matches():
|
||||||
|
return {"matches": get_listing_matches()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/realestate/listings/rematch")
|
||||||
|
def api_listings_rematch():
|
||||||
|
"""MOLIT 재수집 없이 기존 listings+market_deals로 즉시 재판정(즉시 피드백용, 알림 미발송)."""
|
||||||
|
with match_notify_lock:
|
||||||
|
run_listing_matching()
|
||||||
|
matches = get_listing_matches()
|
||||||
|
passed = sum(1 for m in matches if m.get("passed"))
|
||||||
|
judged = sum(1 for m in matches if m.get("passed")
|
||||||
|
and (m.get("safety_tier") or m.get("valuation_tier")) not in (None, "보류"))
|
||||||
|
return {"total": len(matches), "passed": passed, "judged": judged}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/realestate/safety-check")
|
||||||
|
def api_safety_check(req: SafetyCheckRequest):
|
||||||
|
deals = get_market_deals_for(req.dong_code, req.complex_name, req.area,
|
||||||
|
"전세" if req.deal_type != "매매" else "매매")
|
||||||
|
reg = finance_rules.region_regulation(req.dong) # dong명(없으면 토허 판정 생략)
|
||||||
|
if req.deal_type == "매매":
|
||||||
|
res = compute_valuation(req.amount, deals)
|
||||||
|
ratio_key = "price_ratio"
|
||||||
|
else:
|
||||||
|
res = compute_safety(req.amount, req.deal_type, deals)
|
||||||
|
ratio_key = "jeonse_ratio"
|
||||||
|
return {"median": res["median"], "ratio": res.get(ratio_key), "tier": res["tier"],
|
||||||
|
"sample": res["sample"], "is_toheo": reg["is_toheo"],
|
||||||
|
"disclaimer": "등기부 선순위·토허 허가·실거주 의무는 계약 전 수동 확인 필수. " + DISCLAIMER}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/realestate/budget")
|
||||||
|
def api_budget(req: BudgetRequest):
|
||||||
|
reg = finance_rules.region_regulation(req.target_dong)
|
||||||
|
return {
|
||||||
|
"jeonse": finance_rules.estimate_jeonse_budget(req.equity, req.annual_income),
|
||||||
|
"purchase": finance_rules.estimate_purchase_budget(req.equity, req.annual_income, reg,
|
||||||
|
req.is_first_home, req.is_homeless),
|
||||||
|
"region": reg,
|
||||||
|
"disclaimer": DISCLAIMER,
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,3 +84,38 @@ class ProfileUpdate(BaseModel):
|
|||||||
preferred_districts: Optional[Dict[str, List[str]]] = None
|
preferred_districts: Optional[Dict[str, List[str]]] = None
|
||||||
min_match_score: Optional[int] = Field(default=None, ge=0, le=100)
|
min_match_score: Optional[int] = Field(default=None, ge=0, le=100)
|
||||||
notify_enabled: Optional[bool] = None
|
notify_enabled: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ListingCriteriaUpdate(BaseModel):
|
||||||
|
dongs: Optional[List[str]] = None
|
||||||
|
deal_types: Optional[List[str]] = None
|
||||||
|
max_deposit: Optional[int] = None
|
||||||
|
max_sale_price: Optional[int] = None
|
||||||
|
min_area: Optional[float] = None
|
||||||
|
house_types: Optional[List[str]] = None
|
||||||
|
min_safety_tier: Optional[str] = None
|
||||||
|
notify_enabled: Optional[bool] = None
|
||||||
|
equity: Optional[int] = None
|
||||||
|
annual_income: Optional[int] = None
|
||||||
|
is_homeless: Optional[bool] = None
|
||||||
|
is_householder: Optional[bool] = None
|
||||||
|
is_first_home: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SafetyCheckRequest(BaseModel):
|
||||||
|
complex_name: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
dong: Optional[str] = None
|
||||||
|
dong_code: Optional[str] = None
|
||||||
|
area: float
|
||||||
|
deal_type: str
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetRequest(BaseModel):
|
||||||
|
equity: int
|
||||||
|
annual_income: int
|
||||||
|
is_homeless: bool = True
|
||||||
|
is_householder: bool = False
|
||||||
|
is_first_home: bool = False
|
||||||
|
target_dong: Optional[str] = None
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .db import get_profile, get_unnotified_matches, mark_matches_notified
|
from .db import (get_profile, get_unnotified_matches, mark_matches_notified,
|
||||||
|
get_listing_criteria, get_unnotified_listing_matches, mark_listings_notified)
|
||||||
|
|
||||||
logger = logging.getLogger("realestate-lab")
|
logger = logging.getLogger("realestate-lab")
|
||||||
|
|
||||||
AGENT_OFFICE_URL = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000")
|
AGENT_OFFICE_URL = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000")
|
||||||
NOTIFY_TIMEOUT_SECONDS = int(os.getenv("REALESTATE_NOTIFY_TIMEOUT", "15"))
|
NOTIFY_TIMEOUT_SECONDS = int(os.getenv("REALESTATE_NOTIFY_TIMEOUT", "15"))
|
||||||
|
LISTING_NOTIFY_MAX = int(os.getenv("LISTING_NOTIFY_MAX", "8"))
|
||||||
|
LISTING_NOTIFY_TIMEOUT = int(os.getenv("LISTING_NOTIFY_TIMEOUT", "60"))
|
||||||
|
|
||||||
|
|
||||||
def notify_new_matches() -> dict:
|
def notify_new_matches() -> dict:
|
||||||
@@ -44,3 +47,34 @@ def notify_new_matches() -> dict:
|
|||||||
mark_matches_notified(sent_ids)
|
mark_matches_notified(sent_ids)
|
||||||
logger.info("알림 송신: %d건", len(sent_ids))
|
logger.info("알림 송신: %d건", len(sent_ids))
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def notify_new_listings() -> dict:
|
||||||
|
"""임계 통과 미알림 매물을 agent-office로 push (notify_new_matches 대칭).
|
||||||
|
passed 매물은 전 안전등급 알림(위험도 회피 목적 유용). criteria.min_safety_tier 설정 시 그 이상만.
|
||||||
|
콜드스타트로 backlog가 쌓이면 한 사이클 최대 LISTING_NOTIFY_MAX건만 알림 발송하고
|
||||||
|
나머지는 조용히 notified_at 마킹(baseline)해 스팸·재전송 루프를 막는다."""
|
||||||
|
crit = get_listing_criteria()
|
||||||
|
if not crit.get("notify_enabled"):
|
||||||
|
return {"sent": 0, "skipped": "notify_disabled"}
|
||||||
|
listings = get_unnotified_listing_matches(min_tier=crit.get("min_safety_tier"))
|
||||||
|
if not listings:
|
||||||
|
return {"sent": 0}
|
||||||
|
to_send = listings[:LISTING_NOTIFY_MAX]
|
||||||
|
to_baseline = listings[LISTING_NOTIFY_MAX:]
|
||||||
|
if to_baseline: # 콜드스타트 backlog: 조용히 baseline 마킹(알림 X)
|
||||||
|
mark_listings_notified([m["id"] for m in to_baseline])
|
||||||
|
logger.info("매물 알림 baseline(무알림) 마킹: %d건", len(to_baseline))
|
||||||
|
url = f"{AGENT_OFFICE_URL}/api/agent-office/realestate/notify-listing"
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json={"listings": to_send}, timeout=LISTING_NOTIFY_TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
body = resp.json()
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error("agent-office 매물 push 실패: %s", e)
|
||||||
|
return {"sent": 0, "error": str(e), "baselined": len(to_baseline)}
|
||||||
|
sent_ids = body.get("sent_ids") or []
|
||||||
|
if sent_ids:
|
||||||
|
mark_listings_notified(sent_ids)
|
||||||
|
logger.info("매물 알림 송신: %d건", len(sent_ids))
|
||||||
|
return body
|
||||||
|
|||||||
5
realestate-lab/app/pipeline_lock.py
Normal file
5
realestate-lab/app/pipeline_lock.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""매물 매칭+알림 임계구역 직렬화 락 — cron 파이프라인과 워커 ingest가 공유해
|
||||||
|
run_listing_matching()+notify_new_listings() 동시 실행(중복 텔레그램)을 방지한다."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
match_notify_lock = threading.Lock()
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# 네이버 매물 fetch 워커 — BE 구현 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:** naver-fetch 워커가 네이버 호가 매물을 push할 수 있도록 realestate-lab에 내부 계약(targets 조회 + listings ingest), 관측 등재, cortarNo 매핑을 구축한다. 워커(web-ai)·FE(/infra)는 별도 세션.
|
||||||
|
|
||||||
|
**Architecture:** 워커는 `GET /api/internal/realestate/targets`로 대상 동+10자리 cortarNo를 받고, 네이버 raw 매물을 `POST /api/internal/realestate/listings-ingest`로 push. NAS는 기존 `_parse_naver_article`로 파싱→`upsert_listing`→`run_listing_matching`→`notify_new_listings`. 인증은 render 워커와 동형(X-Internal-Key + nginx IP화이트리스트). 관측은 heartbeat + node_monitor `fetcher` kind.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12, FastAPI(APIRouter+Depends), SQLite, pytest. 외부 호출 테스트는 전부 mock.
|
||||||
|
|
||||||
|
**스펙:** `realestate-lab/docs/superpowers/specs/2026-07-09-naver-listing-worker-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **기존 매물/청약 파이프라인 불변** — 신규 내부 라우터·매핑·등재만 추가. 안전마진 판정 로직 불변.
|
||||||
|
- **인증 패턴 준수**: `verify_internal_key(x_internal_key: str = Header(...))`, env **`INTERNAL_API_KEY`**, 실패 시 `HTTPException(401, "Invalid X-Internal-Key")`. (스펙의 `REALESTATE_INTERNAL_KEY` 대신 기존 서비스 관례 `INTERNAL_API_KEY` 사용 — image/video/insta-lab 일관성.)
|
||||||
|
- **파싱은 한 곳**: 네이버 article 파싱은 기존 `listing_collector._parse_naver_article` 재사용(중복 구현 금지).
|
||||||
|
- **멱등**: `listings.article_no` UNIQUE upsert + `listing_matches.notified_at` → 재push해도 재알림 없음.
|
||||||
|
- **테스트**: `cd realestate-lab && PYTHONPATH="C:/Users/jaeoh/Desktop/workspace/web-backend" python -m pytest tests/<file> -q` (main import은 `_shared` 때문에 PYTHONPATH에 web-backend 루트 필요). agent-office 테스트는 `cd agent-office && python -m pytest`.
|
||||||
|
- **cortarNo는 10자리 법정동코드** (기존 `LAWD_CODES` 5자리 시군구는 MOLIT 전용 유지).
|
||||||
|
- **nginx/deploy는 locked 리소스**: nginx 변경·push는 `nginx-conf`·`nas-deploy` 락 획득 후(Task 6).
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 파일 | 신규/수정 | 책임 |
|
||||||
|
|---|---|---|
|
||||||
|
| `realestate-lab/app/lawd_codes.py` | Modify | `NAVER_CORTAR`(동→10자리) + `naver_cortar()` 추가 |
|
||||||
|
| `realestate-lab/app/auth.py` | Create | `verify_internal_key` (X-Internal-Key) |
|
||||||
|
| `realestate-lab/app/internal_router.py` | Create | `GET targets` + `POST listings-ingest` |
|
||||||
|
| `realestate-lab/app/main.py` | Modify | `include_router(internal_router)` |
|
||||||
|
| `nginx/default.conf` | Modify | `/api/internal/realestate/` 블록 |
|
||||||
|
| `realestate-lab/app/listing_collector.py` | Modify | `collect_listings` MOLIT 전용화(naver deprecate) |
|
||||||
|
| `agent-office/app/node_monitor.py` | Modify | `WORKER_REGISTRY` fetcher 등재 + link 분기 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: NAVER_CORTAR 매핑 (10자리 법정동코드)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `realestate-lab/app/lawd_codes.py`
|
||||||
|
- Test: `realestate-lab/tests/test_lawd_codes.py` (기존 파일에 append)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `NAVER_CORTAR: dict[str,str]`, `naver_cortar(dong: str) -> str | None`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패 테스트 append** — `tests/test_lawd_codes.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_naver_cortar_10digit():
|
||||||
|
from app.lawd_codes import naver_cortar, NAVER_CORTAR
|
||||||
|
assert naver_cortar("신대방동") == "1159010100"
|
||||||
|
assert naver_cortar("봉천동") == "1162010100"
|
||||||
|
assert naver_cortar("신길동") == "1156013200"
|
||||||
|
assert naver_cortar("없는동") is None
|
||||||
|
# 6개 초기 대상 동 전부 10자리
|
||||||
|
for d in ["신대방동","대방동","상도동","봉천동","대림동","신길동"]:
|
||||||
|
assert len(NAVER_CORTAR[d]) == 10
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인** — `cd realestate-lab && PYTHONPATH="C:/Users/jaeoh/Desktop/workspace/web-backend" python -m pytest tests/test_lawd_codes.py -q` → FAIL(ImportError naver_cortar)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 구현** — `app/lawd_codes.py` 하단에 추가
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 네이버부동산 cortarNo = 10자리 법정동코드 (LAWD_CODES 5자리 시군구와 별개).
|
||||||
|
# 출처: 행정표준코드관리시스템 법정동코드. 대상 동 추가 시 여기에.
|
||||||
|
NAVER_CORTAR = {
|
||||||
|
"신대방동": "1159010100",
|
||||||
|
"대방동": "1159010200",
|
||||||
|
"상도동": "1159010600",
|
||||||
|
"봉천동": "1162010100",
|
||||||
|
"대림동": "1156013600",
|
||||||
|
"신길동": "1156013200",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def naver_cortar(dong: str) -> str | None:
|
||||||
|
return NAVER_CORTAR.get((dong or "").strip())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add realestate-lab/app/lawd_codes.py realestate-lab/tests/test_lawd_codes.py
|
||||||
|
git commit -m "feat(realestate): 네이버 cortarNo 10자리 법정동 매핑(NAVER_CORTAR)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 내부 인증 + targets 엔드포인트 + 라우터 배선
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `realestate-lab/app/auth.py`, `realestate-lab/app/internal_router.py`
|
||||||
|
- Modify: `realestate-lab/app/main.py`
|
||||||
|
- Test: `realestate-lab/tests/test_internal_targets.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task1 `naver_cortar`; 기존 `db.get_listing_criteria`.
|
||||||
|
- Produces: `verify_internal_key` dependency; `GET /api/internal/realestate/targets` → `{"dongs":[{"dong","cortar_no"}],"deal_types":[...],"page_limit":int}`; `router` (APIRouter, main에 include).
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패 테스트** — `tests/test_internal_targets.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
def _client(monkeypatch):
|
||||||
|
monkeypatch.setenv("INTERNAL_API_KEY", "SECRET")
|
||||||
|
import importlib
|
||||||
|
from app import auth as _a
|
||||||
|
importlib.reload(_a) # env 반영
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_targets_requires_key(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
assert c.get("/api/internal/realestate/targets").status_code == 401 # 헤더 없음
|
||||||
|
assert c.get("/api/internal/realestate/targets",
|
||||||
|
headers={"X-Internal-Key": "WRONG"}).status_code == 401
|
||||||
|
|
||||||
|
def test_targets_returns_dongs_with_cortar(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
r = c.get("/api/internal/realestate/targets", headers={"X-Internal-Key": "SECRET"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
dongs = {d["dong"]: d["cortar_no"] for d in body["dongs"]}
|
||||||
|
assert dongs.get("신대방동") == "1159010100" # criteria seed 6동 + cortar join
|
||||||
|
assert "전세" in body["deal_types"]
|
||||||
|
assert isinstance(body["page_limit"], int)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인** — `PYTHONPATH=... python -m pytest tests/test_internal_targets.py -q` → FAIL(404/ImportError)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 구현**
|
||||||
|
|
||||||
|
`app/auth.py`:
|
||||||
|
```python
|
||||||
|
"""Windows naver-fetch 워커 → NAS realestate-lab 내부 엔드포인트 인증."""
|
||||||
|
import os
|
||||||
|
from fastapi import Header, HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def verify_internal_key(x_internal_key: str = Header(...)):
|
||||||
|
expected = os.getenv("INTERNAL_API_KEY")
|
||||||
|
if not expected:
|
||||||
|
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
|
||||||
|
if x_internal_key != expected:
|
||||||
|
raise HTTPException(401, "Invalid X-Internal-Key")
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/internal_router.py`:
|
||||||
|
```python
|
||||||
|
"""naver-fetch 워커 내부 계약: targets 조회 + listings ingest."""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from .auth import verify_internal_key
|
||||||
|
from .db import get_listing_criteria
|
||||||
|
from .lawd_codes import naver_cortar
|
||||||
|
|
||||||
|
logger = logging.getLogger("realestate-lab")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
NAVER_PAGE_LIMIT = int(os.getenv("NAVER_PAGE_LIMIT", "2"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/internal/realestate/targets", dependencies=[Depends(verify_internal_key)])
|
||||||
|
def listing_targets():
|
||||||
|
crit = get_listing_criteria()
|
||||||
|
dongs = []
|
||||||
|
for d in crit.get("dongs", []):
|
||||||
|
code = naver_cortar(d)
|
||||||
|
if not code:
|
||||||
|
logger.warning("naver cortarNo 매핑 없음 — 대상 제외: %s", d)
|
||||||
|
continue
|
||||||
|
dongs.append({"dong": d, "cortar_no": code})
|
||||||
|
return {"dongs": dongs, "deal_types": crit.get("deal_types", []),
|
||||||
|
"page_limit": NAVER_PAGE_LIMIT}
|
||||||
|
```
|
||||||
|
|
||||||
|
`app/main.py`: import 블록에 추가(기존 `from .models import (...)` 아래)
|
||||||
|
```python
|
||||||
|
from .internal_router import router as internal_router
|
||||||
|
```
|
||||||
|
그리고 `app = FastAPI(lifespan=lifespan)` + `install_access_log(app)` **직후**에:
|
||||||
|
```python
|
||||||
|
app.include_router(internal_router)
|
||||||
|
```
|
||||||
|
|
||||||
|
`nginx/default.conf`: `/api/realestate/` location 블록 **앞**에 추가(더 구체적 prefix 우선):
|
||||||
|
```nginx
|
||||||
|
location /api/internal/realestate/ {
|
||||||
|
allow 192.168.45.0/24;
|
||||||
|
allow 100.64.0.0/10;
|
||||||
|
allow 127.0.0.1;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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-Internal-Key $http_x_internal_key;
|
||||||
|
proxy_pass http://realestate-lab:8000/api/internal/realestate/;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS (nginx는 로컬 테스트 무관, 배포 시 검증)
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add realestate-lab/app/auth.py realestate-lab/app/internal_router.py realestate-lab/app/main.py nginx/default.conf realestate-lab/tests/test_internal_targets.py
|
||||||
|
git commit -m "feat(realestate): 내부 인증 + naver 워커 targets 엔드포인트 + nginx internal 블록"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: listings-ingest 엔드포인트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `realestate-lab/app/internal_router.py`
|
||||||
|
- Test: `realestate-lab/tests/test_internal_ingest.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: 기존 `db.upsert_listing`, `listing_collector._parse_naver_article`, `listing_matcher.run_listing_matching`, `notifier.notify_new_listings`.
|
||||||
|
- Produces: `POST /api/internal/realestate/listings-ingest` body `{fetched_at:str, batches:[{dong:str, articles:[dict]}]}` → `{"received":int,"new":int,"matched":int}`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패 테스트** — `tests/test_internal_ingest.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os, importlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
def _client(monkeypatch):
|
||||||
|
monkeypatch.setenv("INTERNAL_API_KEY", "SECRET")
|
||||||
|
from app import auth as _a
|
||||||
|
importlib.reload(_a)
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
_ARTICLE = {"articleNo": "N100", "tradeTypeName": "전세", "dealOrWarrantPrc": "2억 9,000",
|
||||||
|
"area2": "42.5", "floorInfo": "5/15", "articleName": "OO아파트",
|
||||||
|
"buildingName": "OO", "cortarNo": "1159010100"}
|
||||||
|
|
||||||
|
def test_ingest_auth(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
assert c.post("/api/internal/realestate/listings-ingest", json={"batches": []}).status_code == 401
|
||||||
|
|
||||||
|
def test_ingest_upserts_and_matches(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
with patch("app.internal_router.notify_new_listings", return_value={"sent": 1, "sent_ids": [1]}) as noti:
|
||||||
|
r = c.post("/api/internal/realestate/listings-ingest",
|
||||||
|
headers={"X-Internal-Key": "SECRET"},
|
||||||
|
json={"fetched_at": "2026-07-09T00:00:00Z",
|
||||||
|
"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["received"] == 1 and body["new"] == 1 and body["matched"] == 1
|
||||||
|
assert noti.call_count == 1
|
||||||
|
# 저장 확인
|
||||||
|
from app import db
|
||||||
|
ls = db.get_listings()
|
||||||
|
assert any(l["article_no"] == "N100" and l["dong"] == "신대방동" for l in ls)
|
||||||
|
|
||||||
|
def test_ingest_idempotent(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
payload = {"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]}
|
||||||
|
with patch("app.internal_router.notify_new_listings", return_value={"sent": 0}):
|
||||||
|
c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload)
|
||||||
|
r2 = c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload)
|
||||||
|
assert r2.json()["new"] == 0 # 재push → 신규 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인** — `PYTHONPATH=... python -m pytest tests/test_internal_ingest.py -q` → FAIL(404)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 구현** — `app/internal_router.py`에 추가
|
||||||
|
|
||||||
|
상단 import 확장:
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from .db import get_listing_criteria, upsert_listing
|
||||||
|
from .listing_collector import _parse_naver_article
|
||||||
|
from .listing_matcher import run_listing_matching
|
||||||
|
from .notifier import notify_new_listings
|
||||||
|
```
|
||||||
|
(기존 `from .db import get_listing_criteria`는 위 확장 라인으로 합치거나 중복 없이 정리)
|
||||||
|
|
||||||
|
모델 + 핸들러:
|
||||||
|
```python
|
||||||
|
class _Batch(BaseModel):
|
||||||
|
dong: str
|
||||||
|
articles: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ListingsIngest(BaseModel):
|
||||||
|
fetched_at: str | None = None
|
||||||
|
batches: List[_Batch] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/internal/realestate/listings-ingest",
|
||||||
|
dependencies=[Depends(verify_internal_key)])
|
||||||
|
def listings_ingest(body: ListingsIngest):
|
||||||
|
received = new = 0
|
||||||
|
for batch in body.batches:
|
||||||
|
for raw in batch.articles:
|
||||||
|
try:
|
||||||
|
d = _parse_naver_article(raw)
|
||||||
|
if not d.get("article_no"):
|
||||||
|
continue
|
||||||
|
d["dong"] = batch.dong
|
||||||
|
_, is_new = upsert_listing(d)
|
||||||
|
received += 1
|
||||||
|
if is_new:
|
||||||
|
new += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("네이버 ingest 파싱 실패: %s", e)
|
||||||
|
matched = 0
|
||||||
|
if received:
|
||||||
|
run_listing_matching()
|
||||||
|
noti = notify_new_listings()
|
||||||
|
matched = int(noti.get("sent", 0)) if isinstance(noti, dict) else 0
|
||||||
|
return {"received": received, "new": new, "matched": matched}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add realestate-lab/app/internal_router.py realestate-lab/tests/test_internal_ingest.py
|
||||||
|
git commit -m "feat(realestate): naver 워커 listings-ingest(파싱→upsert→매칭→알림)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: node_monitor fetcher 등재
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `agent-office/app/node_monitor.py`
|
||||||
|
- Test: `agent-office/tests/test_node_monitor.py` (기존 파일에 append; 없으면 `test_node_monitor_fetcher.py` 생성)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `WORKER_REGISTRY`에 `{"name":"naver-fetch","kind":"fetcher","queue":None}`; `collect_status()`가 fetcher에 대해 `{from:"naver-fetch", to:"nas-realestate", type:"http-pull", status:...}` link 생성.
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패 테스트** — 등재 여부(트리비얼) + 링크 분기
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_naver_fetch_registered():
|
||||||
|
from app.node_monitor import WORKER_REGISTRY
|
||||||
|
entry = next((w for w in WORKER_REGISTRY if w["name"] == "naver-fetch"), None)
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["kind"] == "fetcher" and entry["queue"] is None
|
||||||
|
```
|
||||||
|
> collect_status의 fetcher-link는 기존 `test_node_monitor.py`의 redis-mock 픽스처(trader/render 링크 검증부)를 그대로 따라, "naver-fetch heartbeat 존재 시 links에 from=naver-fetch·type=http-pull이 있다"를 추가 검증하라. 픽스처가 없으면 이 트리비얼 등재 테스트만으로 충분.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인** — `cd agent-office && python -m pytest tests/test_node_monitor.py -q` (또는 신규 파일) → FAIL
|
||||||
|
|
||||||
|
- [ ] **Step 3: 구현** — `agent-office/app/node_monitor.py`
|
||||||
|
|
||||||
|
`WORKER_REGISTRY` 리스트에 추가:
|
||||||
|
```python
|
||||||
|
{"name": "naver-fetch", "kind": "fetcher", "queue": None},
|
||||||
|
```
|
||||||
|
|
||||||
|
`collect_status()`의 kind 분기(`if w["kind"] == "trader": ... elif w["kind"] == "render": ...`)에 추가:
|
||||||
|
```python
|
||||||
|
elif w["kind"] == "fetcher":
|
||||||
|
out["links"].append({"from": w["name"], "to": "nas-realestate", "type": "http-pull",
|
||||||
|
"status": "healthy" if w["alive"] else "down"})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 통과 확인** — 같은 pytest → PASS + 회귀 `cd agent-office && python -m pytest -q`
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add agent-office/app/node_monitor.py agent-office/tests/test_node_monitor.py
|
||||||
|
git commit -m "feat(agent-office): node_monitor에 naver-fetch(fetcher) 등재 + http-pull 링크"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: collect_listings MOLIT 전용화 (naver deprecate)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `realestate-lab/app/listing_collector.py`
|
||||||
|
- Test: `realestate-lab/tests/test_listing_collector.py` (기존 수정)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- `collect_listings()` → `{"new_count":0,"total_count":0,"naver_ok":None,"diag":str}` (naver는 워커가 담당하므로 NAS는 MOLIT만). `_fetch_naver_all`/`_parse_naver_article`/`_won_to_manwon`는 **존치**(ingest가 `_parse_naver_article` 사용).
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패 테스트** — 기존 `test_collect_naver_block_sets_naver_ok_false`를 대체
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_collect_is_molit_only(monkeypatch):
|
||||||
|
"""collect_listings는 MOLIT만 수행(naver fetch 미호출). naver는 워커 담당."""
|
||||||
|
from app import listing_collector as lc
|
||||||
|
monkeypatch.setattr(lc, "_fetch_molit_all", lambda: (5, "calls=1 saved=5"))
|
||||||
|
called = {"naver": False}
|
||||||
|
def naver_spy():
|
||||||
|
called["naver"] = True
|
||||||
|
return (0, 0)
|
||||||
|
monkeypatch.setattr(lc, "_fetch_naver_all", naver_spy)
|
||||||
|
r = lc.collect_listings()
|
||||||
|
assert called["naver"] is False # naver 미호출
|
||||||
|
assert "molit" in r["diag"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인** — `PYTHONPATH=... python -m pytest tests/test_listing_collector.py -q` → FAIL(naver 호출됨)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 구현** — `collect_listings` 교체
|
||||||
|
|
||||||
|
```python
|
||||||
|
def collect_listings() -> dict:
|
||||||
|
"""MOLIT 실거래(합법 baseline)만 수집. 네이버 호가는 naver-fetch 워커가
|
||||||
|
/api/internal/realestate/listings-ingest로 push (spec 2026-07-09-naver-listing-worker)."""
|
||||||
|
molit_diag = None
|
||||||
|
try:
|
||||||
|
_, molit_diag = _fetch_molit_all()
|
||||||
|
except Exception as e:
|
||||||
|
molit_diag = f"exc={type(e).__name__}:{str(e)[:80]}"
|
||||||
|
logger.error("MOLIT 수집 오류(안전마진 baseline): %s", e)
|
||||||
|
diag = f"molit[{molit_diag}] naver[worker]"
|
||||||
|
save_listing_collect_log(0, 0, None, error=diag)
|
||||||
|
return {"new_count": 0, "total_count": 0, "naver_ok": None, "diag": diag}
|
||||||
|
```
|
||||||
|
> `save_listing_collect_log`의 naver_ok 인자는 `1 if naver_ok else 0` 처리이므로 None→0 저장(무해). `_fetch_naver_all`/`_parse_naver_article`는 삭제하지 말 것(ingest 재사용).
|
||||||
|
> 기존 `test_collect_writes_molit_diag_to_log`(계측 테스트)가 `_fetch_naver_all`을 `lambda: (0,0)`로 mock하는데 이제 미호출이므로 여전히 통과(무해). `test_molit_call_returns_diag_on_http_error`·`_parse_*`도 무관.
|
||||||
|
|
||||||
|
- [ ] **Step 4: 통과 확인** — `PYTHONPATH=... python -m pytest tests/test_listing_collector.py -q` → PASS (신규 + 기존 계측/파서 테스트)
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add realestate-lab/app/listing_collector.py realestate-lab/tests/test_listing_collector.py
|
||||||
|
git commit -m "refactor(realestate): collect_listings MOLIT 전용화(네이버는 워커 ingest로 이전)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: 회귀 + 카탈로그/메모리 + 배포 + 계약 핸드오프
|
||||||
|
|
||||||
|
**Files:** `CLAUDE.md`, 메모리 `service_realestate.md`/`infra_distributed_workers.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 전체 회귀** — `cd realestate-lab && PYTHONPATH="C:/Users/jaeoh/Desktop/workspace/web-backend" python -m pytest -q` (0 fail) + `cd agent-office && python -m pytest -q` (0 fail)
|
||||||
|
- [ ] **Step 2: CLAUDE.md** — §5 nginx 표에 `/api/internal/realestate/`(IP화이트리스트) 추가, §9 realestate 표에 `GET /api/internal/realestate/targets`·`POST /listings-ingest` 추가
|
||||||
|
- [ ] **Step 3: 메모리** — `service_realestate.md`(내부 계약·NAVER_CORTAR·collect MOLIT전용화), `infra_distributed_workers.md`(naver-fetch fetcher kind 등재, /api/internal/realestate/ 계약)
|
||||||
|
- [ ] **Step 4: 계약 잠금·핸드오프** — co-gahusb `acquire_lock("nginx-conf","BE")`+`acquire_lock("nas-deploy","BE")` → 커밋 push(자동배포). AI(web-ai)에 §4 계약(targets/ingest 스키마·heartbeat kind=fetcher·INTERNAL_API_KEY)·병렬 spike 요청, FE(web-ui)에 /infra fetcher 노드 요청 `post_message`. 배포 후 `GET /api/internal/realestate/targets`(X-Internal-Key) 200 검증 → 락 해제.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs(CLAUDE.md): naver 워커 internal 엔드포인트 + nginx 등재"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:** §4.1 targets→T2, §4.2 ingest→T3, §4.3 cortarNo→T1, §4.4 heartbeat(계약, 워커측)→T4 등재로 관측, §5 관측→T4, §6 인증(nginx+X-Internal-Key)→T2, §7 멱등/에러→T3(try/except·article_no UNIQUE), §8 테스트→각 Task, §3 _fetch_naver_all deprecate→T5, §10 핸드오프→T6. ✓
|
||||||
|
- 스펙 대비 조정: env명 `INTERNAL_API_KEY`(기존 관례, 스펙의 REALESTATE_INTERNAL_KEY 대체) — Global Constraints에 명시. matched=notify sent 수로 구체화(스펙 {received,new,matched} 유지).
|
||||||
|
|
||||||
|
**Placeholder scan:** 각 Step 실제 코드/명령. T4의 collect_status fetcher-link 상세 테스트만 "기존 test_node_monitor 픽스처 따라"로 위임(기존 파일 참조, 트리비얼 등재 테스트는 완전). ✓
|
||||||
|
|
||||||
|
**Type consistency:** `naver_cortar`(T1)→internal_router(T2) 사용 일치. `upsert_listing`→`(dict,is_new)`(기존)·`_parse_naver_article`(기존 반환 dict)·`notify_new_listings`→`{sent,...}`(기존)→ingest 소비 일치. targets 반환 `{dongs:[{dong,cortar_no}],deal_types,page_limit}`=스펙 §4.1. ingest 반환 `{received,new,matched}`=스펙 §4.2. WORKER_REGISTRY 항목 형식(T4)=기존 구조 일치. ✓
|
||||||
|
|
||||||
|
**범위:** BE 단일 세션(realestate T1-3,5 + agent-office T4 + 통합 T6). 워커(web-ai)·FE(web-ui)는 별도. 단일 실행 계획 적정(6 tasks).
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# 네이버 매물 fetch 워커 (naver-fetch) — 설계
|
||||||
|
|
||||||
|
> 상태: **설계 승인됨(2026-07-09) → 구현 계획(writing-plans) 대기**
|
||||||
|
> 관련: [[2026-07-09-매물알림-안전마진-design]](매물 파이프라인 본체), `infra_distributed_workers` 메모리(워커↔NAS 계약·관측)
|
||||||
|
|
||||||
|
## 1. 배경 / 목표
|
||||||
|
|
||||||
|
매물 알림 파이프라인(2026-07-09 배포)에서 **국토부 실거래(합법 baseline)는 정상**이나, **네이버 호가 매물 수집은 NAS(datacenter IP)에서 `ReadTimeout/429`로 차단**되어 자동 매물 유입이 막혀 있다. → 네이버 fetch를 **Windows AI 머신(192.168.45.59, 가정 IP)**의 신규 워커에서 수행해 NAS로 push한다. 기존 render/trade-monitor 워커와 **동형 패턴**.
|
||||||
|
|
||||||
|
**성공 기준**: 워커가 대상 동의 네이버 호가 매물을 주기적으로 fetch → NAS가 upsert→매칭→(안전마진 판정 포함)→신규 매물 텔레그램 알림. 워커는 `/infra`에서 관측 가능. 네이버가 일시 차단돼도 실거래·안전마진·청약 무중단.
|
||||||
|
|
||||||
|
## 2. 비목표 (Out of Scope)
|
||||||
|
|
||||||
|
- 네이버 인증 토큰 획득 전략(SPA Bearer JWT 등) — **web-ai 워커의 구현·병렬 spike**로 확인(BE 계약과 독립).
|
||||||
|
- 단지(complex)별 심층 크롤·과거 backfill — MVP는 동(법정동)별 article list만.
|
||||||
|
- 안전마진/적정성 판정 로직 — 이미 존재·불변(`listing_matcher`).
|
||||||
|
- `market_deals` 수집 성능 개선(행별 개별 `_conn` upsert가 대량시 느림) — 별개 후속(관측됨, §9 참조).
|
||||||
|
|
||||||
|
## 3. 아키텍처 (3-repo 분담)
|
||||||
|
|
||||||
|
```
|
||||||
|
[web-ai: naver-fetch 워커] (Windows 192.168.45.59, WSL2 docker, 가정 IP)
|
||||||
|
자체 스케줄 루프(2~3h, 낮 시간):
|
||||||
|
1. GET NAS /api/internal/realestate/targets (X-Internal-Key)
|
||||||
|
→ {dongs:[{dong, cortar_no}], deal_types, page_limit}
|
||||||
|
2. 네이버 new.land.naver.com/api/articles?cortarNo=<10자리>&... 동별 fetch
|
||||||
|
(인증 토큰 확보는 워커 책임 — 병렬 spike)
|
||||||
|
3. POST NAS /api/internal/realestate/listings-ingest (X-Internal-Key)
|
||||||
|
{fetched_at, batches:[{dong, articles:[<raw 네이버 article>...]}]}
|
||||||
|
4. worker:naver-fetch:heartbeat (EX45, kind=fetcher, state=idle/fetching)
|
||||||
|
|
||||||
|
[web-backend: NAS realestate-lab] (BE 담당)
|
||||||
|
- GET /api/internal/realestate/targets → criteria.dongs + cortar_no 매핑 + criteria.deal_types
|
||||||
|
- POST /api/internal/realestate/listings-ingest
|
||||||
|
→ 각 article _parse_naver_article(기존·검증) + dong 태깅 → upsert_listing
|
||||||
|
→ run_listing_matching() + notify_new_listings() (기존 재사용)
|
||||||
|
→ {received, new, matched}
|
||||||
|
- node_monitor.WORKER_REGISTRY += naver-fetch(kind=fetcher)
|
||||||
|
- nginx: /api/internal/realestate/ → realestate-lab:8000 (IP 화이트리스트)
|
||||||
|
- collect_listings의 _fetch_naver_all deprecated (MOLIT 실거래 cron은 유지)
|
||||||
|
|
||||||
|
[web-ui: /infra] (FE 담당, 소규모)
|
||||||
|
- fetcher kind 노드 시각(statusVisual 색·라벨)
|
||||||
|
```
|
||||||
|
|
||||||
|
**설계 원칙**: 워커는 **무상태 얇은 프록시**("네이버 IP차단 우회 fetch"만). 파싱·dedup·매칭·알림·상태는 전부 NAS. 파싱은 NAS의 검증된 `_parse_naver_article` 한 곳에서만 유지(네이버 포맷 변경 시 BE만 수정).
|
||||||
|
|
||||||
|
## 4. 계약 (BE 소유 — cross-repo lock)
|
||||||
|
|
||||||
|
### 4.1 `GET /api/internal/realestate/targets` (X-Internal-Key)
|
||||||
|
워커가 "무엇을 긁을지" pull.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dongs": [
|
||||||
|
{"dong": "신대방동", "cortar_no": "1159010100"},
|
||||||
|
{"dong": "봉천동", "cortar_no": "1162010100"}
|
||||||
|
],
|
||||||
|
"deal_types": ["전세", "반전세", "매매"],
|
||||||
|
"page_limit": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `dongs`: `listing_criteria.dongs` 각각에 대해 `NAVER_CORTAR[dong]`(10자리 법정동코드) 조인. 매핑 없는 동은 제외(+경고 로그).
|
||||||
|
- `deal_types`: `listing_criteria.deal_types`.
|
||||||
|
- `page_limit`: 동당 fetch 페이지 수 상한(기본 2). 상수/env.
|
||||||
|
- 인증 실패(키 불일치/누락) → 401.
|
||||||
|
|
||||||
|
### 4.2 `POST /api/internal/realestate/listings-ingest` (X-Internal-Key)
|
||||||
|
워커가 네이버 raw 매물 push.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fetched_at": "2026-07-09T05:44:23Z",
|
||||||
|
"batches": [
|
||||||
|
{"dong": "신대방동", "articles": [ { /* 네이버 articleList item raw */ }, ... ]},
|
||||||
|
{"dong": "봉천동", "articles": [ ... ]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 각 batch의 각 article → `_parse_naver_article(article)` → `d["dong"] = batch.dong` → `upsert_listing(d)`. article별 try/except(불량 1건 skip).
|
||||||
|
- 전체 upsert 후 `run_listing_matching()` + `notify_new_listings()` 호출(신규 매물만 알림, 멱등).
|
||||||
|
- 응답: `{"received": N, "new": M, "matched": K}` (received=처리 article 수, new=신규 upsert 수, **matched=이번 호출에서 신규 텔레그램 발송 건수(`notify_new_listings().sent`)** — 누적 통과 매칭 수가 아님).
|
||||||
|
- 인증 실패 → 401. `batches` 비면 no-op `{received:0,new:0,matched:0}`.
|
||||||
|
- ⚠️ MVP는 인라인 처리(매물 수 수백 규모). 대량 시 upsert만 동기+매칭/알림 BackgroundTask로 분리 여지(§9).
|
||||||
|
|
||||||
|
### 4.3 cortarNo 매핑 (BE)
|
||||||
|
`app/lawd_codes.py`에 `NAVER_CORTAR: dict[str,str]`(동명→**10자리 법정동코드**) 추가. 현재 `LAWD_CODES`(5자리 시군구)는 MOLIT 전용으로 유지 — 네이버는 10자리 필요(기존 `_fetch_naver_all`이 5자리를 넘기던 버그를 이 매핑으로 해소). 초기 대상 6동:
|
||||||
|
| 동 | 구 | 10자리(구현 시 행정표준코드로 검증) |
|
||||||
|
|---|---|---|
|
||||||
|
| 신대방동 | 동작구 | 1159010100 |
|
||||||
|
| 대방동 | 동작구 | 1159010200 |
|
||||||
|
| 상도동 | 동작구 | 1159010600 |
|
||||||
|
| 봉천동 | 관악구 | 1162010100 |
|
||||||
|
| 대림동 | 영등포구 | 1156013600 |
|
||||||
|
| 신길동 | 영등포구 | 1156013200 |
|
||||||
|
> 위 코드는 구현 시 행정표준코드관리시스템으로 최종 검증(법정동 통폐합 반영).
|
||||||
|
|
||||||
|
### 4.4 heartbeat (워커→Redis, 관측 계약)
|
||||||
|
`worker:naver-fetch:heartbeat` EX45, 값 JSON:
|
||||||
|
```json
|
||||||
|
{"name":"naver-fetch","kind":"fetcher","state":"idle|fetching","ts":<epoch>,
|
||||||
|
"last_fetch_at":<epoch|null>,"listings_pushed":<int>,"errors":<int>}
|
||||||
|
```
|
||||||
|
- `kind="fetcher"`는 신규(기존 render/watcher/trader에 추가). state 의미: idle=대기, fetching=수집중.
|
||||||
|
|
||||||
|
## 5. 관측 (팀 규칙 3단계 — `infra_distributed_workers`)
|
||||||
|
|
||||||
|
1. **워커 heartbeat** (§4.4) — web-ai.
|
||||||
|
2. **BE 등재**: `agent-office/app/node_monitor.py`의 `WORKER_REGISTRY += {"name":"naver-fetch","kind":"fetcher","queue":None}`. `collect_status()`가 `fetcher` kind를 처리(heartbeat 유무→alive/dead, link type=`http-pull`(ai_trade/trade-monitor와 동일, redis-queue 아님)). 다운/복구 텔레그램 경보는 기존 1분 cron 재사용(`_notified` 패턴).
|
||||||
|
3. **FE**: `/infra` `statusVisual`에 `fetcher` 라벨·색 + http-pull 링크. (web-ui, 소규모)
|
||||||
|
|
||||||
|
## 6. 인증 / 보안
|
||||||
|
|
||||||
|
- **nginx**: 신규 location `/api/internal/realestate/` → `realestate-lab:8000`, **IP 화이트리스트**(기존 render 워커 화이트리스트에 쓰는 Windows 워커 IP 재사용). (nginx-conf 변경 → 배포)
|
||||||
|
- **앱 레벨**: realestate-lab이 `X-Internal-Key` 검증(env `INTERNAL_API_KEY`). 다른 서비스 `internal_router` 패턴 준용. GET targets·POST ingest 둘 다 검증.
|
||||||
|
- 워커는 `.env`로 키 주입(커밋 금지).
|
||||||
|
|
||||||
|
## 7. 에러처리 / 멱등성
|
||||||
|
|
||||||
|
- 워커: 네이버 차단/토큰만료 → 사이클 skip, 다음 폴링 재시도. heartbeat는 계속 발신(생사 가시성). errors 카운트 증가.
|
||||||
|
- NAS ingest: article별 try/except(1건 실패 격리). 멱등 — `listings.article_no` UNIQUE upsert + `listing_matches.notified_at` → 같은 매물 재push해도 재알림 없음.
|
||||||
|
- NAS `collect_listings`: `_fetch_naver_all` 미호출로 변경(MOLIT만). `naver_ok`/naver diag는 collect_log에서 의미 상실 → MOLIT 전용 로그로 정리(컬럼은 nullable 유지, 하위호환).
|
||||||
|
|
||||||
|
## 8. 테스트 (BE)
|
||||||
|
|
||||||
|
- `test_internal_targets`: criteria seed 후 GET → dongs에 cortar_no 조인, deal_types 반환. 매핑 없는 동 제외.
|
||||||
|
- `test_internal_ingest`: raw 네이버 article batch POST → upsert_listing 호출 + `run_listing_matching`/`notify_new_listings` 호출(mock) + `{received,new,matched}`. 멱등(재POST시 new=0).
|
||||||
|
- `test_internal_auth`: X-Internal-Key 없거나 틀리면 401.
|
||||||
|
- `test_naver_cortar_mapping`: `NAVER_CORTAR` 10자리·대상 6동.
|
||||||
|
- 워커 테스트는 web-ai(별도).
|
||||||
|
|
||||||
|
## 9. 후속 / 알려진 개선점 (비차단)
|
||||||
|
|
||||||
|
- `market_deals`/`listings` upsert가 행별 개별 `_conn()` 커밋 → 대량 시 Celeron에서 느림(관측: 매매 승인 후 collect 수 분). 배치 트랜잭션(단일 `_conn` executemany)로 개선 여지.
|
||||||
|
- 네이버 인증 토큰 만료·갱신 자동화(워커, web-ai).
|
||||||
|
- page_limit·수집 주기 튜닝(네이버 rate-limit 관측 후).
|
||||||
|
|
||||||
|
## 10. 의존성 / 분담 요약
|
||||||
|
|
||||||
|
| repo | 작업 | 세션 |
|
||||||
|
|---|---|---|
|
||||||
|
| web-backend | targets/ingest 엔드포인트, X-Internal-Key, NAVER_CORTAR 매핑, node_monitor fetcher 등재, nginx `/api/internal/realestate/`, `_fetch_naver_all` deprecate | **BE(이 세션)** |
|
||||||
|
| web-ai | naver-fetch 워커(폴링+네이버 fetch+토큰+push+heartbeat) + Dockerfile `_shared` 포함 확인 | AI (병렬 spike 포함) |
|
||||||
|
| web-ui | `/infra` fetcher 노드 시각 | FE (소규모) |
|
||||||
|
|
||||||
|
계약(§4)은 co-gahusb로 잠그고 3세션 병렬. 계약 변경 시 web-ai+web-backend(+web-ui) 동시 수정.
|
||||||
@@ -16,6 +16,11 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
|
|
||||||
# 테이블 목록 — init_db가 생성하는 모든 테이블
|
# 테이블 목록 — init_db가 생성하는 모든 테이블
|
||||||
_USER_TABLES = (
|
_USER_TABLES = (
|
||||||
|
"listing_matches", # FK CASCADE 대비 자식 테이블 먼저
|
||||||
|
"listings",
|
||||||
|
"market_deals",
|
||||||
|
"listing_criteria",
|
||||||
|
"listing_collect_log",
|
||||||
"match_results", # FK CASCADE 대비 자식 테이블 먼저
|
"match_results", # FK CASCADE 대비 자식 테이블 먼저
|
||||||
"announcement_models",
|
"announcement_models",
|
||||||
"announcements",
|
"announcements",
|
||||||
|
|||||||
21
realestate-lab/tests/test_finance_rules.py
Normal file
21
realestate-lab/tests/test_finance_rules.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
def test_region_regulation_toheo_and_normal():
|
||||||
|
from app.finance_rules import region_regulation
|
||||||
|
assert region_regulation("대치동")["is_toheo"] is True # 강남 토허
|
||||||
|
r = region_regulation("신대방동")
|
||||||
|
assert r["is_toheo"] is False and "확인" in r["notes"]
|
||||||
|
|
||||||
|
def test_jeonse_budget_equity_plus_loan():
|
||||||
|
from app.finance_rules import estimate_jeonse_budget
|
||||||
|
b = estimate_jeonse_budget(equity=15000, annual_income=6000) # 자기자금 1.5억
|
||||||
|
assert b["max_deposit"] == b["loan_limit"] + 15000
|
||||||
|
assert b["loan_limit"] > 0
|
||||||
|
|
||||||
|
def test_purchase_budget_seoul_cap_applies():
|
||||||
|
from app.finance_rules import region_regulation, estimate_purchase_budget
|
||||||
|
flags = region_regulation("상도동")
|
||||||
|
b = estimate_purchase_budget(equity=30000, annual_income=8000,
|
||||||
|
region_flags=flags, is_first_home=False, is_homeless=True)
|
||||||
|
# 수도권 주담대 한도(6·27 대책 6억=60000만원) 캡이 대출가능액에 반영
|
||||||
|
assert b["loan_cap"] <= 60000
|
||||||
|
assert b["max_price"] == b["loan_cap"] + 30000
|
||||||
|
assert 0 < b["ltv_pct"] <= 100
|
||||||
81
realestate-lab/tests/test_internal_ingest.py
Normal file
81
realestate-lab/tests/test_internal_ingest.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import os, importlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
def _client(monkeypatch):
|
||||||
|
monkeypatch.setenv("INTERNAL_API_KEY", "SECRET")
|
||||||
|
from app import auth as _a
|
||||||
|
importlib.reload(_a)
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
_ARTICLE = {"articleNo": "N100", "tradeTypeName": "전세", "dealOrWarrantPrc": "2억 9,000",
|
||||||
|
"area2": "42.5", "floorInfo": "5/15", "articleName": "OO아파트",
|
||||||
|
"buildingName": "OO", "cortarNo": "1159010100"}
|
||||||
|
|
||||||
|
def test_ingest_auth(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
# 헤더 없음 — FastAPI Header(...)는 dependency 실행 전 자체 검증하므로 422.
|
||||||
|
# test_internal_targets.py의 동일 패턴과 일치(같은 서비스 선례).
|
||||||
|
assert c.post("/api/internal/realestate/listings-ingest", json={"batches": []}).status_code in (401, 422)
|
||||||
|
assert c.post("/api/internal/realestate/listings-ingest",
|
||||||
|
headers={"X-Internal-Key": "WRONG"}, json={"batches": []}).status_code == 401
|
||||||
|
|
||||||
|
def test_ingest_upserts_and_matches(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
# TestClient는 BackgroundTask를 응답 반환 전 동기 실행하므로 patch된 notify는
|
||||||
|
# c.post()가 리턴하는 시점에 이미 호출되어 있다.
|
||||||
|
with patch("app.internal_router.notify_new_listings", return_value={"sent": 1, "sent_ids": [1]}) as noti:
|
||||||
|
r = c.post("/api/internal/realestate/listings-ingest",
|
||||||
|
headers={"X-Internal-Key": "SECRET"},
|
||||||
|
json={"fetched_at": "2026-07-09T00:00:00Z",
|
||||||
|
"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["received"] == 1 and body["new"] == 1 and body["queued"] is True
|
||||||
|
assert noti.call_count == 1
|
||||||
|
# 저장 확인
|
||||||
|
from app import db
|
||||||
|
ls = db.get_listings()
|
||||||
|
assert any(l["article_no"] == "N100" and l["dong"] == "신대방동" for l in ls)
|
||||||
|
|
||||||
|
def test_ingest_idempotent(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
payload = {"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]}
|
||||||
|
with patch("app.internal_router.notify_new_listings", return_value={"sent": 0}):
|
||||||
|
c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload)
|
||||||
|
r2 = c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload)
|
||||||
|
assert r2.json()["new"] == 0 # 재push → 신규 0
|
||||||
|
|
||||||
|
def test_ingest_holds_match_notify_lock(monkeypatch):
|
||||||
|
from app import internal_router as ir
|
||||||
|
from app.pipeline_lock import match_notify_lock
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
holds = {}
|
||||||
|
def fake_notify():
|
||||||
|
holds["locked"] = match_notify_lock.locked()
|
||||||
|
return {"sent": 0}
|
||||||
|
monkeypatch.setattr(ir, "notify_new_listings", fake_notify)
|
||||||
|
c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"},
|
||||||
|
json={"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]})
|
||||||
|
assert holds["locked"] is True # 알림 시점에 락 보유(임계구역 직렬화 증명)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 근본원인 fix: 네이버 article에 cortarNo 없으면 dong_code=None → 시세 매칭 불가 ──
|
||||||
|
|
||||||
|
_ARTICLE_NO_CORTAR = {"articleNo": "N200", "tradeTypeName": "전세", "dealOrWarrantPrc": "2억 9,000",
|
||||||
|
"area2": "42.5", "floorInfo": "5/15", "articleName": "OO아파트",
|
||||||
|
"buildingName": "OO"} # cortarNo 없음 — 실제 네이버 응답 케이스
|
||||||
|
|
||||||
|
|
||||||
|
def test_ingest_derives_dong_code_from_dong_when_cortarno_missing(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
with patch("app.internal_router.notify_new_listings", return_value={"sent": 0}):
|
||||||
|
r = c.post("/api/internal/realestate/listings-ingest",
|
||||||
|
headers={"X-Internal-Key": "SECRET"},
|
||||||
|
json={"batches": [{"dong": "신길동", "articles": [_ARTICLE_NO_CORTAR]}]})
|
||||||
|
assert r.status_code == 200
|
||||||
|
from app import db
|
||||||
|
ls = db.get_listings()
|
||||||
|
saved = next(l for l in ls if l["article_no"] == "N200")
|
||||||
|
assert saved["dong_code"] == "11560" # 신길동 → 영등포구 5자리 lawd_code로 유도
|
||||||
28
realestate-lab/tests/test_internal_targets.py
Normal file
28
realestate-lab/tests/test_internal_targets.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import os
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
def _client(monkeypatch):
|
||||||
|
monkeypatch.setenv("INTERNAL_API_KEY", "SECRET")
|
||||||
|
import importlib
|
||||||
|
from app import auth as _a
|
||||||
|
importlib.reload(_a) # env 반영
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_targets_requires_key(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
# 헤더 없음 — FastAPI Header(...)는 dependency 실행 전 자체 검증하므로 422.
|
||||||
|
# image-lab/tests/test_internal_router.py의 동일 패턴과 일치(sibling 서비스 선례).
|
||||||
|
assert c.get("/api/internal/realestate/targets").status_code in (401, 422)
|
||||||
|
assert c.get("/api/internal/realestate/targets",
|
||||||
|
headers={"X-Internal-Key": "WRONG"}).status_code == 401
|
||||||
|
|
||||||
|
def test_targets_returns_dongs_with_cortar(monkeypatch):
|
||||||
|
c = _client(monkeypatch)
|
||||||
|
r = c.get("/api/internal/realestate/targets", headers={"X-Internal-Key": "SECRET"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
dongs = {d["dong"]: d["cortar_no"] for d in body["dongs"]}
|
||||||
|
assert dongs.get("신대방동") == "1159010100" # criteria seed 6동 + cortar join
|
||||||
|
assert "전세" in body["deal_types"]
|
||||||
|
assert isinstance(body["page_limit"], int)
|
||||||
17
realestate-lab/tests/test_lawd_codes.py
Normal file
17
realestate-lab/tests/test_lawd_codes.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
def test_lawd_code_maps_target_dongs():
|
||||||
|
from app.lawd_codes import lawd_code
|
||||||
|
assert lawd_code("신대방동") == "11590" # 동작구
|
||||||
|
assert lawd_code("봉천동") == "11620" # 관악구
|
||||||
|
assert lawd_code("신길동") == "11560" # 영등포구
|
||||||
|
assert lawd_code("없는동") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_naver_cortar_10digit():
|
||||||
|
from app.lawd_codes import naver_cortar, NAVER_CORTAR
|
||||||
|
assert naver_cortar("신대방동") == "1159010100"
|
||||||
|
assert naver_cortar("봉천동") == "1162010100"
|
||||||
|
assert naver_cortar("신길동") == "1156013200"
|
||||||
|
assert naver_cortar("없는동") is None
|
||||||
|
# 6개 초기 대상 동 전부 10자리
|
||||||
|
for d in ["신대방동","대방동","상도동","봉천동","대림동","신길동"]:
|
||||||
|
assert len(NAVER_CORTAR[d]) == 10
|
||||||
58
realestate-lab/tests/test_listing_api.py
Normal file
58
realestate-lab/tests/test_listing_api.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_criteria_get_default_and_put():
|
||||||
|
c = _client()
|
||||||
|
assert c.get("/api/realestate/listings/criteria").json()["max_deposit"] == 32000
|
||||||
|
c.put("/api/realestate/listings/criteria", json={"max_deposit": 30000})
|
||||||
|
assert c.get("/api/realestate/listings/criteria").json()["max_deposit"] == 30000
|
||||||
|
|
||||||
|
def test_budget_endpoint():
|
||||||
|
r = _client().post("/api/realestate/budget", json={"equity": 30000, "annual_income": 8000,
|
||||||
|
"is_homeless": True, "is_householder": True, "is_first_home": False, "target_dong": "상도동"})
|
||||||
|
b = r.json()
|
||||||
|
assert "jeonse" in b and "purchase" in b and "region" in b
|
||||||
|
assert b["purchase"]["max_price"] == b["purchase"]["loan_cap"] + 30000
|
||||||
|
|
||||||
|
def test_safety_check_rent():
|
||||||
|
from app import db
|
||||||
|
for v in (40000, 42000, 44000):
|
||||||
|
db.upsert_market_deal({"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
|
||||||
|
"complex_name": "OO", "area": 42.0, "deal_type": "전세",
|
||||||
|
"deposit": v, "sale_amount": None, "deal_ym": "202606", "floor": "5"})
|
||||||
|
r = _client().post("/api/realestate/safety-check",
|
||||||
|
json={"complex_name": "OO", "area": 42.0, "deal_type": "전세",
|
||||||
|
"amount": 28000, "dong_code": "11590"})
|
||||||
|
j = r.json()
|
||||||
|
assert j["tier"] == "안전" and "disclaimer" in j
|
||||||
|
|
||||||
|
def test_listings_collect_status():
|
||||||
|
assert _client().get("/api/realestate/listings/collect/status").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_listings_rematch_endpoint():
|
||||||
|
"""MOLIT 재수집 없이 기존 listings+market_deals로 즉시 재판정 — 200 + 요약 카운트."""
|
||||||
|
from app import db
|
||||||
|
for i, v in enumerate((40000, 42000, 44000)):
|
||||||
|
db.upsert_market_deal({"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
|
||||||
|
"complex_name": "OO", "area": 42.0, "deal_type": "전세",
|
||||||
|
"deposit": v, "sale_amount": None,
|
||||||
|
"deal_ym": f"2026{i+1:02d}", "floor": "5", "source": "molit"})
|
||||||
|
db.upsert_listing({"article_no": "RM1", "source": "naver", "deal_type": "전세",
|
||||||
|
"deposit": 28000, "area_exclusive": 42.0, "complex_name": "OO",
|
||||||
|
"dong": "신대방동", "dong_code": "11590"})
|
||||||
|
db.update_listing_criteria({"dongs": ["신대방동"], "deal_types": ["전세"], "max_deposit": 32000,
|
||||||
|
"min_area": 40.0, "house_types": ["아파트"]})
|
||||||
|
|
||||||
|
r = _client().post("/api/realestate/listings/rematch")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["passed"] == 1
|
||||||
|
assert body["judged"] == 1
|
||||||
|
|
||||||
|
matches = db.get_listing_matches()
|
||||||
|
assert matches[0]["safety_tier"] == "안전"
|
||||||
57
realestate-lab/tests/test_listing_collector.py
Normal file
57
realestate-lab/tests/test_listing_collector.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
def test_parse_molit_rent_fields():
|
||||||
|
from app.listing_collector import _parse_molit_rent
|
||||||
|
raw = {"aptNm": "OO아파트", "excluUseAr": "42.5", "deposit": "43,000",
|
||||||
|
"monthlyRent": "0", "dealYear": "2026", "dealMonth": "5", "floor": "5"}
|
||||||
|
d = _parse_molit_rent(raw, "아파트")
|
||||||
|
assert d["complex_name"] == "OO아파트" and d["area"] == 42.5
|
||||||
|
assert d["deposit"] == 43000 and d["deal_type"] == "전세" and d["deal_ym"] == "202605"
|
||||||
|
|
||||||
|
def test_parse_naver_article_fields():
|
||||||
|
from app.listing_collector import _parse_naver_article
|
||||||
|
raw = {"articleNo": "A123", "tradeTypeName": "전세", "dealOrWarrantPrc": "2억 9,000",
|
||||||
|
"areaName": "42", "area2": "42.5", "floorInfo": "5/15", "articleName": "OO아파트",
|
||||||
|
"buildingName": "OO", "cortarNo": "1159010100"}
|
||||||
|
d = _parse_naver_article(raw)
|
||||||
|
assert d["article_no"] == "A123" and d["deal_type"] == "전세" and d["deposit"] == 29000
|
||||||
|
assert d["source"] == "naver"
|
||||||
|
|
||||||
|
def test_collect_is_molit_only(monkeypatch):
|
||||||
|
"""collect_listings는 MOLIT만 수행(naver fetch 미호출). naver는 워커 담당."""
|
||||||
|
from app import listing_collector as lc
|
||||||
|
monkeypatch.setattr(lc, "_fetch_molit_all", lambda: (5, "calls=1 saved=5"))
|
||||||
|
called = {"naver": False}
|
||||||
|
def naver_spy():
|
||||||
|
called["naver"] = True
|
||||||
|
return (0, 0)
|
||||||
|
monkeypatch.setattr(lc, "_fetch_naver_all", naver_spy)
|
||||||
|
r = lc.collect_listings()
|
||||||
|
assert called["naver"] is False # naver 미호출
|
||||||
|
assert "molit" in r["diag"]
|
||||||
|
|
||||||
|
def test_molit_call_returns_diag_on_http_error(monkeypatch):
|
||||||
|
"""MOLIT 401 등 HTTP 오류 → ([], diag) 반환, diag에 상태코드 포함(관측)."""
|
||||||
|
from app import listing_collector as lc
|
||||||
|
import requests
|
||||||
|
monkeypatch.setattr(lc, "MOLIT_KEY", "TESTKEY")
|
||||||
|
monkeypatch.setattr(lc.time, "sleep", lambda *a: None) # 재시도 대기 skip
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status_code = 401
|
||||||
|
resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
|
||||||
|
monkeypatch.setattr(lc.requests, "get", lambda *a, **k: resp)
|
||||||
|
items, diag = lc._molit_call("getRTMSDataSvcAptTradeDev", "11590", "202606")
|
||||||
|
assert items == []
|
||||||
|
assert diag and "401" in diag
|
||||||
|
|
||||||
|
def test_collect_writes_molit_diag_to_log(monkeypatch):
|
||||||
|
"""collect_listings가 MOLIT/Naver 진단을 collect_log.error에 기록(silent-failure 관측)."""
|
||||||
|
from app import listing_collector as lc
|
||||||
|
from app import db
|
||||||
|
monkeypatch.setattr(lc, "_fetch_molit_all", lambda: (0, "calls=36 saved=0 err=http=401"))
|
||||||
|
monkeypatch.setattr(lc, "_fetch_naver_all", lambda: (0, 0))
|
||||||
|
lc.collect_listings()
|
||||||
|
log = db.get_last_listing_collect_log()
|
||||||
|
assert log is not None
|
||||||
|
assert "http=401" in (log["error"] or "")
|
||||||
|
assert "molit" in (log["error"] or "")
|
||||||
166
realestate-lab/tests/test_listing_db.py
Normal file
166
realestate-lab/tests/test_listing_db.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
def test_listing_criteria_seed_and_update():
|
||||||
|
from app import db
|
||||||
|
c = db.get_listing_criteria()
|
||||||
|
assert c["id"] == 1 and "신대방동" in c["dongs"] and c["max_deposit"] == 32000
|
||||||
|
db.update_listing_criteria({"max_deposit": 30000, "equity": 15000})
|
||||||
|
assert db.get_listing_criteria()["max_deposit"] == 30000
|
||||||
|
assert db.get_listing_criteria()["equity"] == 15000
|
||||||
|
|
||||||
|
def test_upsert_listing_idempotent_and_is_new():
|
||||||
|
from app import db
|
||||||
|
row = {"article_no": "A1", "source": "naver", "deal_type": "전세", "deposit": 29000,
|
||||||
|
"area_exclusive": 42.0, "complex_name": "OO", "dong": "신대방동", "dong_code": "11590"}
|
||||||
|
_, is_new = db.upsert_listing(row)
|
||||||
|
assert is_new is True
|
||||||
|
_, is_new2 = db.upsert_listing({**row, "deposit": 28000})
|
||||||
|
assert is_new2 is False
|
||||||
|
ls = db.get_listings()
|
||||||
|
assert len(ls) == 1 and ls[0]["deposit"] == 28000
|
||||||
|
|
||||||
|
def test_market_deals_dedup_and_query():
|
||||||
|
from app import db
|
||||||
|
base = {"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
|
||||||
|
"complex_name": "OO", "area": 42.0, "deal_type": "전세",
|
||||||
|
"deposit": 43000, "sale_amount": None, "deal_ym": "202605", "floor": "5", "source": "molit"}
|
||||||
|
db.upsert_market_deal(base)
|
||||||
|
db.upsert_market_deal(base) # 동일 → dedup
|
||||||
|
deals = db.get_market_deals_for("11590", "OO", 42.0, "전세", months=120)
|
||||||
|
assert len(deals) == 1
|
||||||
|
|
||||||
|
def test_unnotified_listing_match_and_mark():
|
||||||
|
from app import db
|
||||||
|
lid, _ = db.upsert_listing({"article_no": "A2", "source": "naver", "deal_type": "전세",
|
||||||
|
"deposit": 29000, "area_exclusive": 42.0, "dong": "신대방동"})
|
||||||
|
db.upsert_listing_match({"listing_id": lid[0] if isinstance(lid, tuple) else lid["id"],
|
||||||
|
"category": "임차", "passed": 1, "match_score": 90,
|
||||||
|
"safety_tier": "안전", "sample_size": 7, "reasons": ["ok"], "is_new": 1})
|
||||||
|
un = db.get_unnotified_listing_matches()
|
||||||
|
assert len(un) == 1
|
||||||
|
db.mark_listings_notified([un[0]["id"]])
|
||||||
|
assert db.get_unnotified_listing_matches() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── 최종 리뷰 fix (M1/M3/M4) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_market_deals_broad_fallback_when_complex_sample_thin():
|
||||||
|
"""complex_name 지정 시 표본이 MIN_SAMPLE(3) 미만이면 complex 제약을 풀고
|
||||||
|
같은 dong_code/deal_type/area±5/recency 조건으로 광역 재조회해야 한다."""
|
||||||
|
from app import db
|
||||||
|
base = {"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
|
||||||
|
"area": 42.0, "deal_type": "전세", "sale_amount": None, "floor": "5", "source": "molit"}
|
||||||
|
# complex "A" 단독 1건 (< MIN_SAMPLE=3)
|
||||||
|
db.upsert_market_deal({**base, "complex_name": "A", "deposit": 30000, "deal_ym": "202601"})
|
||||||
|
# complex "B" 3건 (dedup 인덱스 회피 위해 deal_ym 다르게)
|
||||||
|
db.upsert_market_deal({**base, "complex_name": "B", "deposit": 31000, "deal_ym": "202602"})
|
||||||
|
db.upsert_market_deal({**base, "complex_name": "B", "deposit": 32000, "deal_ym": "202603"})
|
||||||
|
db.upsert_market_deal({**base, "complex_name": "B", "deposit": 33000, "deal_ym": "202604"})
|
||||||
|
|
||||||
|
deals = db.get_market_deals_for("11590", "A", 42.0, "전세", months=120)
|
||||||
|
assert len(deals) == 4 # A 1건 + B 3건 광역 폴백
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_market_deal_area_null_skipped():
|
||||||
|
"""area=None인 실거래는 median 계산에 무용 + dedup 인덱스도 못 걸려 무한중복
|
||||||
|
유발하므로 저장 자체를 skip해야 한다."""
|
||||||
|
from app import db
|
||||||
|
d = {"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
|
||||||
|
"complex_name": "OO", "area": None, "deal_type": "전세",
|
||||||
|
"deposit": 43000, "sale_amount": None, "deal_ym": "202605", "floor": "5", "source": "molit"}
|
||||||
|
db.upsert_market_deal(d)
|
||||||
|
db.upsert_market_deal(d) # 재호출해도 여전히 저장 안 됨(무한중복 방지 확인)
|
||||||
|
with db._conn() as conn:
|
||||||
|
cnt = conn.execute("SELECT COUNT(*) FROM market_deals").fetchone()[0]
|
||||||
|
assert cnt == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_upsert_market_deals(monkeypatch):
|
||||||
|
from app import db
|
||||||
|
rows = [
|
||||||
|
{"house_type":"아파트","dong_code":"11590","dong":"신대방동","complex_name":"OO","area":42.0,
|
||||||
|
"deal_type":"전세","deposit":43000,"sale_amount":None,"deal_ym":"202606","floor":"5"},
|
||||||
|
{"house_type":"아파트","dong_code":"11590","dong":"신대방동","complex_name":"OO","area":42.0,
|
||||||
|
"deal_type":"전세","deposit":43000,"sale_amount":None,"deal_ym":"202606","floor":"5"}, # dup
|
||||||
|
{"house_type":"아파트","dong_code":"11590","dong":None,"complex_name":None,"area":None,
|
||||||
|
"deal_type":"매매","sale_amount":90000,"deal_ym":"202606","floor":"3"}, # area None → skip
|
||||||
|
]
|
||||||
|
n = db.bulk_upsert_market_deals(rows)
|
||||||
|
assert n == 2 # area=None 1건 skip
|
||||||
|
deals = db.get_market_deals_for("11590","OO",42.0,"전세",months=120)
|
||||||
|
assert len(deals) == 1 # dup dedup → 1건
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_listing_matches_json_parsed():
|
||||||
|
"""get_listing_matches()도 get_unnotified_listing_matches()와 동일하게
|
||||||
|
regulation_flags/reasons를 list로 파싱해 반환해야 한다."""
|
||||||
|
from app import db
|
||||||
|
lid, _ = db.upsert_listing({"article_no": "A3", "source": "naver", "deal_type": "전세",
|
||||||
|
"deposit": 29000, "area_exclusive": 42.0, "dong": "신대방동"})
|
||||||
|
db.upsert_listing_match({"listing_id": lid["id"], "category": "임차", "passed": 1,
|
||||||
|
"match_score": 90, "safety_tier": "안전", "sample_size": 7,
|
||||||
|
"regulation_flags": ["토지거래허가구역"], "reasons": ["ok"], "is_new": 1})
|
||||||
|
matches = db.get_listing_matches()
|
||||||
|
assert len(matches) == 1
|
||||||
|
assert isinstance(matches[0]["regulation_flags"], list)
|
||||||
|
assert matches[0]["regulation_flags"] == ["토지거래허가구역"]
|
||||||
|
assert isinstance(matches[0]["reasons"], list)
|
||||||
|
assert matches[0]["reasons"] == ["ok"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Task 2: run_listing_matching 단일 conn 성능개선 ──────────────────────────
|
||||||
|
|
||||||
|
def test_get_market_deals_for_reuses_passed_conn():
|
||||||
|
"""conn 인자를 넘기면 그 connection을 재사용하고, 넘기지 않았을 때와 동일한
|
||||||
|
결과를 반환해야 한다(1차 complex 스코프 → 표본 부족 시 광역 폴백 로직 불변)."""
|
||||||
|
from app import db
|
||||||
|
base = {"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
|
||||||
|
"complex_name": "OO", "area": 42.0, "deal_type": "전세",
|
||||||
|
"sale_amount": None, "floor": "5", "source": "molit"}
|
||||||
|
db.upsert_market_deal({**base, "deposit": 40000, "deal_ym": "202601"})
|
||||||
|
db.upsert_market_deal({**base, "deposit": 41000, "deal_ym": "202602"})
|
||||||
|
db.upsert_market_deal({**base, "deposit": 42000, "deal_ym": "202603"})
|
||||||
|
|
||||||
|
without_conn = db.get_market_deals_for("11590", "OO", 42.0, "전세", months=120)
|
||||||
|
with db._conn() as shared:
|
||||||
|
with_conn = db.get_market_deals_for("11590", "OO", 42.0, "전세", months=120, conn=shared)
|
||||||
|
# conn을 넘겼으니 함수가 스스로 닫지 않아야 함 — 같은 conn으로 후속 쿼리 가능해야 한다.
|
||||||
|
still_open = shared.execute("SELECT COUNT(*) FROM market_deals").fetchone()[0]
|
||||||
|
assert len(with_conn) == len(without_conn) == 3
|
||||||
|
assert still_open == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_upsert_listing_matches_multi_insert_and_update():
|
||||||
|
"""bulk_upsert_listing_matches가 여러 건을 한 번에 upsert하고, 재호출 시
|
||||||
|
기존 upsert_listing_match와 동일하게 갱신(notified_at 보존)해야 한다."""
|
||||||
|
from app import db
|
||||||
|
l1, _ = db.upsert_listing({"article_no": "B1", "source": "naver", "deal_type": "전세",
|
||||||
|
"deposit": 29000, "area_exclusive": 42.0, "dong": "신대방동"})
|
||||||
|
l2, _ = db.upsert_listing({"article_no": "B2", "source": "naver", "deal_type": "매매",
|
||||||
|
"sale_price": 85000, "area_exclusive": 59.0, "dong": "상도동"})
|
||||||
|
db.bulk_upsert_listing_matches([
|
||||||
|
{"listing_id": l1["id"], "category": "임차", "passed": 1, "match_score": 100,
|
||||||
|
"safety_tier": "안전", "sample_size": 5, "reasons": ["ok"], "is_new": 1},
|
||||||
|
{"listing_id": l2["id"], "category": "매매", "passed": 0, "match_score": 0,
|
||||||
|
"valuation_tier": "고가", "sample_size": 4, "reasons": ["매수가능 상한 초과"], "is_new": 1},
|
||||||
|
])
|
||||||
|
matches = {m["listing_id"]: m for m in db.get_listing_matches()}
|
||||||
|
assert len(matches) == 2
|
||||||
|
assert matches[l1["id"]]["safety_tier"] == "안전" and matches[l1["id"]]["passed"] == 1
|
||||||
|
assert matches[l2["id"]]["valuation_tier"] == "고가" and matches[l2["id"]]["passed"] == 0
|
||||||
|
|
||||||
|
# notified_at 마킹 후 재호출해도 보존돼야 함(재알림 방지)
|
||||||
|
match_id = next(m["id"] for m in db.get_listing_matches() if m["listing_id"] == l1["id"])
|
||||||
|
db.mark_listings_notified([match_id])
|
||||||
|
db.bulk_upsert_listing_matches([
|
||||||
|
{"listing_id": l1["id"], "category": "임차", "passed": 1, "match_score": 95,
|
||||||
|
"safety_tier": "주의", "sample_size": 6, "reasons": ["ok"], "is_new": 1},
|
||||||
|
])
|
||||||
|
updated = {m["listing_id"]: m for m in db.get_listing_matches()}
|
||||||
|
assert updated[l1["id"]]["safety_tier"] == "주의" # 갱신됨
|
||||||
|
assert updated[l1["id"]]["notified_at"] is not None # 보존됨
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_upsert_listing_matches_empty_noop():
|
||||||
|
"""빈 리스트는 아무 것도 하지 않는다(가드)."""
|
||||||
|
from app import db
|
||||||
|
db.bulk_upsert_listing_matches([])
|
||||||
|
assert db.get_listing_matches() == []
|
||||||
111
realestate-lab/tests/test_listing_matcher.py
Normal file
111
realestate-lab/tests/test_listing_matcher.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import statistics
|
||||||
|
def _deals(vals, key="deposit", dt="전세"):
|
||||||
|
return [{key: v, "deal_type": dt} for v in vals]
|
||||||
|
|
||||||
|
def test_compute_safety_tiers():
|
||||||
|
from app.listing_matcher import compute_safety
|
||||||
|
deals = _deals([40000, 42000, 44000, 43000]) # median ~42500
|
||||||
|
assert compute_safety(28000, "전세", deals)["tier"] == "안전" # 0.66
|
||||||
|
assert compute_safety(32000, "전세", deals)["tier"] == "주의" # 0.75
|
||||||
|
assert compute_safety(38000, "전세", deals)["tier"] == "위험" # 0.89
|
||||||
|
assert compute_safety(28000, "전세", _deals([40000, 41000]))["tier"] == "보류" # 표본<3
|
||||||
|
|
||||||
|
def test_compute_valuation_tiers():
|
||||||
|
from app.listing_matcher import compute_valuation
|
||||||
|
deals = _deals([80000, 88000, 90000, 87000], key="sale_amount", dt="매매") # median ~87500
|
||||||
|
assert compute_valuation(83000, deals)["tier"] == "저평가" # 0.95
|
||||||
|
assert compute_valuation(89000, deals)["tier"] == "시세" # 1.02
|
||||||
|
assert compute_valuation(95000, deals)["tier"] == "고가" # 1.09
|
||||||
|
|
||||||
|
def test_match_listing_rent_deposit_cap():
|
||||||
|
from app.listing_matcher import match_listing
|
||||||
|
crit = {"dongs": ["신대방동"], "deal_types": ["전세"], "max_deposit": 32000,
|
||||||
|
"min_area": 40.0, "house_types": ["아파트"]}
|
||||||
|
ok = match_listing(crit, {"deal_type": "전세", "deposit": 29000, "area_exclusive": 42.0,
|
||||||
|
"dong": "신대방동"}, budget={"max_deposit": 45000})
|
||||||
|
assert ok["passed"] is True
|
||||||
|
over = match_listing(crit, {"deal_type": "전세", "deposit": 40000, "area_exclusive": 42.0,
|
||||||
|
"dong": "신대방동"}, budget={"max_deposit": 45000})
|
||||||
|
assert over["passed"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Task 2: run_listing_matching 단일 conn 통합 테스트 ───────────────────────
|
||||||
|
|
||||||
|
def test_run_listing_matching_end_to_end_safe_rent_and_overpriced_sale():
|
||||||
|
"""listings+market_deals+criteria를 시드하고 run_listing_matching()을 돌려
|
||||||
|
listing_matches가 실제로 생성되고 tier/passed가 기대대로인지 확인한다
|
||||||
|
(단일 connection 리팩터 후에도 판정 로직이 보존됐는지 검증하는 회귀 테스트)."""
|
||||||
|
from app import db
|
||||||
|
from app.listing_matcher import run_listing_matching
|
||||||
|
|
||||||
|
# criteria: 신대방동 전세/매매, 보증금 상한 32000, 최소면적 40
|
||||||
|
db.update_listing_criteria({
|
||||||
|
"dongs": ["신대방동"], "deal_types": ["전세", "매매"],
|
||||||
|
"max_deposit": 32000, "min_area": 40.0,
|
||||||
|
"house_types": ["아파트"], "equity": 20000, "annual_income": 6000,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 전세 실거래 시세(median ~42000) — 안전 매물(28000, ratio 0.67)
|
||||||
|
for i, v in enumerate((40000, 42000, 44000)):
|
||||||
|
db.upsert_market_deal({"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
|
||||||
|
"complex_name": "OO", "area": 42.0, "deal_type": "전세",
|
||||||
|
"deposit": v, "sale_amount": None,
|
||||||
|
"deal_ym": f"2026{i+1:02d}", "floor": "5", "source": "molit"})
|
||||||
|
l_rent, _ = db.upsert_listing({"article_no": "R1", "source": "naver", "deal_type": "전세",
|
||||||
|
"deposit": 28000, "area_exclusive": 42.0,
|
||||||
|
"complex_name": "OO", "dong": "신대방동", "dong_code": "11590"})
|
||||||
|
|
||||||
|
# 매매 실거래 시세(median ~87500) — 고가 매물(95000, ratio 1.09)
|
||||||
|
for i, v in enumerate((80000, 88000, 90000, 87000)):
|
||||||
|
db.upsert_market_deal({"house_type": "아파트", "dong_code": "11590", "dong": "신대방동",
|
||||||
|
"complex_name": "PP", "area": 59.0, "deal_type": "매매",
|
||||||
|
"deposit": None, "sale_amount": v,
|
||||||
|
"deal_ym": f"2026{i+1:02d}", "floor": "10", "source": "molit"})
|
||||||
|
l_sale, _ = db.upsert_listing({"article_no": "R2", "source": "naver", "deal_type": "매매",
|
||||||
|
"sale_price": 95000, "area_exclusive": 59.0,
|
||||||
|
"complex_name": "PP", "dong": "신대방동", "dong_code": "11590"})
|
||||||
|
|
||||||
|
run_listing_matching()
|
||||||
|
|
||||||
|
matches = {m["listing_id"]: m for m in db.get_listing_matches()}
|
||||||
|
assert len(matches) == 2
|
||||||
|
rent_m = matches[l_rent["id"]]
|
||||||
|
assert rent_m["passed"] == 1
|
||||||
|
assert rent_m["safety_tier"] == "안전"
|
||||||
|
assert rent_m["sample_size"] == 3
|
||||||
|
|
||||||
|
sale_m = matches[l_sale["id"]]
|
||||||
|
assert sale_m["valuation_tier"] == "고가"
|
||||||
|
assert sale_m["sample_size"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ── 근본원인 fix: 기존 저장된 listing.dong_code=None(cortarNo 부재) 행도 dong명에서
|
||||||
|
# lawd_code를 유도해 즉시 판정돼야 한다(보류 고착 방지) ──
|
||||||
|
|
||||||
|
def test_run_listing_matching_derives_dong_code_when_none():
|
||||||
|
from app import db
|
||||||
|
from app.listing_matcher import run_listing_matching
|
||||||
|
|
||||||
|
db.update_listing_criteria({
|
||||||
|
"dongs": ["신길동"], "deal_types": ["매매"],
|
||||||
|
"min_area": 40.0, "house_types": ["아파트"],
|
||||||
|
"equity": 20000, "annual_income": 6000,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 신길동(영등포구=11560) 매매 실거래 시세 3건
|
||||||
|
for i, v in enumerate((80000, 88000, 90000)):
|
||||||
|
db.upsert_market_deal({"house_type": "아파트", "dong_code": "11560", "dong": "신길동",
|
||||||
|
"complex_name": "QQ", "area": 59.0, "deal_type": "매매",
|
||||||
|
"deposit": None, "sale_amount": v,
|
||||||
|
"deal_ym": f"2026{i+1:02d}", "floor": "8", "source": "molit"})
|
||||||
|
# 매물은 dong_code=None으로 저장(네이버 cortarNo 부재 시나리오)
|
||||||
|
l_sale, _ = db.upsert_listing({"article_no": "R3", "source": "naver", "deal_type": "매매",
|
||||||
|
"sale_price": 84000, "area_exclusive": 59.0,
|
||||||
|
"complex_name": "QQ", "dong": "신길동", "dong_code": None})
|
||||||
|
|
||||||
|
run_listing_matching()
|
||||||
|
|
||||||
|
matches = {m["listing_id"]: m for m in db.get_listing_matches()}
|
||||||
|
m = matches[l_sale["id"]]
|
||||||
|
assert m["sample_size"] == 3
|
||||||
|
assert m["valuation_tier"] != "보류"
|
||||||
51
realestate-lab/tests/test_listing_notifier.py
Normal file
51
realestate-lab/tests/test_listing_notifier.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_passed_match():
|
||||||
|
from app import db
|
||||||
|
row, _ = db.upsert_listing({"article_no": "N1", "source": "naver", "deal_type": "전세",
|
||||||
|
"deposit": 29000, "area_exclusive": 42.0, "dong": "신대방동"})
|
||||||
|
db.upsert_listing_match({"listing_id": row["id"], "category": "임차", "passed": 1,
|
||||||
|
"match_score": 100, "safety_tier": "안전", "sample_size": 7,
|
||||||
|
"reasons": ["ok"], "is_new": 1})
|
||||||
|
return db.get_unnotified_listing_matches()[0]["id"]
|
||||||
|
|
||||||
|
def test_notify_listings_pushes_and_marks():
|
||||||
|
from app import notifier, db
|
||||||
|
mid = _seed_passed_match()
|
||||||
|
fake = MagicMock(); fake.json.return_value = {"sent": 1, "sent_ids": [mid]}; fake.raise_for_status.return_value = None
|
||||||
|
with patch.object(notifier.requests, "post", return_value=fake) as post:
|
||||||
|
res = notifier.notify_new_listings()
|
||||||
|
assert post.call_count == 1
|
||||||
|
assert db.get_unnotified_listing_matches() == [] # 마킹됨
|
||||||
|
|
||||||
|
def test_notify_listings_no_mark_on_failure():
|
||||||
|
from app import notifier, db
|
||||||
|
import requests as rq
|
||||||
|
mid = _seed_passed_match()
|
||||||
|
with patch.object(notifier.requests, "post", side_effect=rq.RequestException("down")):
|
||||||
|
notifier.notify_new_listings()
|
||||||
|
assert len(db.get_unnotified_listing_matches()) == 1 # 미마킹→재시도
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_caps_and_baselines(monkeypatch):
|
||||||
|
from app import notifier, db
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
for i in range(10):
|
||||||
|
row, _ = db.upsert_listing({"article_no": f"C{i}", "source": "naver", "deal_type": "전세",
|
||||||
|
"deposit": 29000, "area_exclusive": 42.0, "dong": "신대방동"})
|
||||||
|
db.upsert_listing_match({"listing_id": row["id"], "category": "임차", "passed": 1,
|
||||||
|
"match_score": 100, "safety_tier": "안전", "sample_size": 7,
|
||||||
|
"reasons": ["ok"], "is_new": 1})
|
||||||
|
monkeypatch.setattr(notifier, "LISTING_NOTIFY_MAX", 8)
|
||||||
|
captured = {}
|
||||||
|
fake = MagicMock(); fake.raise_for_status.return_value = None
|
||||||
|
def fake_post(url, json=None, timeout=None):
|
||||||
|
captured["n"] = len(json["listings"])
|
||||||
|
fake.json.return_value = {"sent": len(json["listings"]),
|
||||||
|
"sent_ids": [m["id"] for m in json["listings"]]}
|
||||||
|
return fake
|
||||||
|
monkeypatch.setattr(notifier.requests, "post", fake_post)
|
||||||
|
notifier.notify_new_listings()
|
||||||
|
assert captured["n"] == 8 # 8건만 텔레그램 전송
|
||||||
|
assert len(db.get_unnotified_listing_matches()) == 0 # 나머지 2건 baseline 마킹
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||||
SERVICES="lotto travel-proxy deployer stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab nginx scripts _shared"
|
SERVICES="lotto travel-proxy deployer stock music-lab insta-lab realestate-lab co-gahusb agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab nginx scripts _shared"
|
||||||
|
|
||||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
|||||||
|
|
||||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||||
# docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단)
|
# docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단)
|
||||||
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab frontend"
|
BUILD_TARGETS="lotto travel-proxy stock music-lab insta-lab realestate-lab co-gahusb agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab frontend"
|
||||||
# 컨테이너 이름 (고아 정리용 — blog-lab은 폐기 대상으로 정리 리스트에 유지)
|
# 컨테이너 이름 (고아 정리용 — blog-lab은 폐기 대상으로 정리 리스트에 유지)
|
||||||
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab agent-office personal packs-lab travel-proxy video-lab image-lab tarot-lab saju-lab frontend"
|
CONTAINER_NAMES="lotto stock music-lab insta-lab blog-lab realestate-lab co-gahusb agent-office personal packs-lab travel-proxy video-lab image-lab tarot-lab saju-lab frontend"
|
||||||
# Infra 서비스 (image-based, 영속 데이터 보존을 위해 stop/rm 없이 up만)
|
# Infra 서비스 (image-based, 영속 데이터 보존을 위해 stop/rm 없이 up만)
|
||||||
INFRA_SERVICES="redis"
|
INFRA_SERVICES="redis"
|
||||||
# 헬스체크 대상
|
# 헬스체크 대상
|
||||||
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab redis"
|
HEALTH_ENDPOINTS="lotto stock travel-proxy music-lab insta-lab realestate-lab co-gahusb agent-office personal packs-lab video-lab image-lab tarot-lab saju-lab redis"
|
||||||
# data 디렉토리 (packs-lab은 별도 media/packs 사용)
|
# data 디렉토리 (packs-lab은 별도 media/packs 사용)
|
||||||
DATA_DIRS="music stock insta realestate agent-office personal video image tarot saju"
|
DATA_DIRS="music stock insta realestate agent-office personal video image tarot saju"
|
||||||
|
|
||||||
|
|||||||
180
stock/app/db.py
180
stock/app/db.py
@@ -2,6 +2,7 @@ import sqlite3
|
|||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import datetime as dt
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
from app.screener.schema import ensure_screener_schema
|
from app.screener.schema import ensure_screener_schema
|
||||||
@@ -125,6 +126,42 @@ def init_db():
|
|||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_holdings_sig_ticker "
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_holdings_sig_ticker "
|
||||||
"ON holdings_signals(ticker, date DESC);")
|
"ON holdings_signals(ticker, date DESC);")
|
||||||
|
|
||||||
|
# 실시간 매매 알람: watchlist / alert_state / alert_history
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS watchlist (
|
||||||
|
ticker TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
note TEXT,
|
||||||
|
params_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS trade_alert_state (
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
condition TEXT NOT NULL,
|
||||||
|
currently_firing INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_fired_at TEXT,
|
||||||
|
last_fired_at TEXT,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
PRIMARY KEY (ticker, kind, condition)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS trade_alert_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
condition TEXT NOT NULL,
|
||||||
|
price REAL,
|
||||||
|
detail_json TEXT,
|
||||||
|
fired_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_tah_fired ON trade_alert_history(fired_at DESC)")
|
||||||
|
|
||||||
# Screener 스키마 부트스트랩 (7테이블 + 디폴트 설정 시드)
|
# Screener 스키마 부트스트랩 (7테이블 + 디폴트 설정 시드)
|
||||||
ensure_screener_schema(conn)
|
ensure_screener_schema(conn)
|
||||||
|
|
||||||
@@ -379,3 +416,146 @@ def get_holdings_signal_history(ticker: str, limit: int = 30) -> list:
|
|||||||
"SELECT * FROM holdings_signals WHERE ticker=? ORDER BY date DESC LIMIT ?",
|
"SELECT * FROM holdings_signals WHERE ticker=? ORDER BY date DESC LIMIT ?",
|
||||||
(ticker, limit)).fetchall()
|
(ticker, limit)).fetchall()
|
||||||
return [_row_to_signal(r) for r in rows]
|
return [_row_to_signal(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# --- 실시간 매매 알람: 공통 유틸 ---
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return dt.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Watchlist CRUD ---
|
||||||
|
|
||||||
|
def add_watchlist(ticker: str, name: str = None, note: str = None) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO watchlist(ticker,name,note) VALUES(?,?,?)",
|
||||||
|
(ticker, name, note),
|
||||||
|
)
|
||||||
|
# 이름/노트 갱신(이미 있으면)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE watchlist SET name=COALESCE(?,name), note=COALESCE(?,note) WHERE ticker=?",
|
||||||
|
(name, note, ticker),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_watchlist(ticker: str) -> bool:
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM watchlist WHERE ticker=?", (ticker,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_watchlist() -> list:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM watchlist ORDER BY added_at").fetchall()
|
||||||
|
return [
|
||||||
|
{"ticker": r["ticker"], "name": r["name"], "note": r["note"],
|
||||||
|
"params": json.loads(r["params_json"] or "{}"), "added_at": r["added_at"]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Trade Alert State ---
|
||||||
|
|
||||||
|
def get_alert_state_firing() -> set:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT ticker,kind,condition FROM trade_alert_state WHERE currently_firing=1"
|
||||||
|
).fetchall()
|
||||||
|
return {(r["ticker"], r["kind"], r["condition"]) for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def set_alert_firing(ticker: str, kind: str, condition: str, firing: bool,
|
||||||
|
at_iso: str = None, mark_fired: bool = True) -> None:
|
||||||
|
"""currently_firing 상태 갱신.
|
||||||
|
|
||||||
|
mark_fired=True(기본): 실제 알림 발송 → first/last_fired_at 갱신.
|
||||||
|
mark_fired=False: 쿨다운으로 발송 억제하되 firing 상태만 유지 → 발동시각 미갱신
|
||||||
|
(쿨다운이 계속 연장되지 않도록).
|
||||||
|
"""
|
||||||
|
now = at_iso or _now_iso()
|
||||||
|
with _conn() as conn:
|
||||||
|
if firing and mark_fired:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO trade_alert_state(ticker,kind,condition,currently_firing,first_fired_at,last_fired_at,last_seen_at)
|
||||||
|
VALUES(?,?,?,1,?,?,?)
|
||||||
|
ON CONFLICT(ticker,kind,condition) DO UPDATE SET
|
||||||
|
currently_firing=1,
|
||||||
|
first_fired_at=COALESCE(first_fired_at,excluded.first_fired_at),
|
||||||
|
last_fired_at=excluded.last_fired_at,
|
||||||
|
last_seen_at=excluded.last_seen_at""",
|
||||||
|
(ticker, kind, condition, now, now, now),
|
||||||
|
)
|
||||||
|
elif firing and not mark_fired:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO trade_alert_state(ticker,kind,condition,currently_firing,last_seen_at)
|
||||||
|
VALUES(?,?,?,1,?)
|
||||||
|
ON CONFLICT(ticker,kind,condition) DO UPDATE SET
|
||||||
|
currently_firing=1, last_seen_at=excluded.last_seen_at""",
|
||||||
|
(ticker, kind, condition, now),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE trade_alert_state SET currently_firing=0, last_seen_at=? WHERE ticker=? AND kind=? AND condition=?",
|
||||||
|
(now, ticker, kind, condition),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_alert_last_fired_map() -> dict:
|
||||||
|
"""{(ticker,kind,condition): last_fired_at ISO} — 쿨다운 판정용."""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT ticker,kind,condition,last_fired_at FROM trade_alert_state"
|
||||||
|
).fetchall()
|
||||||
|
return {(r["ticker"], r["kind"], r["condition"]): r["last_fired_at"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def get_ticker_name(ticker: str) -> Optional[str]:
|
||||||
|
"""종목명 해석 — watchlist → portfolio → krx_master 순. 없으면 None."""
|
||||||
|
with _conn() as conn:
|
||||||
|
for sql in (
|
||||||
|
"SELECT name FROM watchlist WHERE ticker=?",
|
||||||
|
"SELECT name FROM portfolio WHERE ticker=? LIMIT 1",
|
||||||
|
"SELECT name FROM krx_master WHERE ticker=?",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
row = conn.execute(sql, (ticker,)).fetchone()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
continue # 일부 테스트 DB엔 해당 테이블 부재
|
||||||
|
if row and row["name"]:
|
||||||
|
return row["name"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def touch_alert_seen(keys: list, at_iso: str) -> None:
|
||||||
|
with _conn() as conn:
|
||||||
|
for (ticker, kind, condition) in keys:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE trade_alert_state SET last_seen_at=? WHERE ticker=? AND kind=? AND condition=?",
|
||||||
|
(at_iso, ticker, kind, condition),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Trade Alert History ---
|
||||||
|
|
||||||
|
def add_alert_history(ticker: str, name: str, kind: str, condition: str, price, detail: dict) -> int:
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO trade_alert_history(ticker,name,kind,condition,price,detail_json) VALUES(?,?,?,?,?,?)",
|
||||||
|
(ticker, name, kind, condition, price, json.dumps(detail or {}, ensure_ascii=False)),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_alert_history(days: int = 7) -> list:
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM trade_alert_history WHERE fired_at >= strftime('%Y-%m-%dT%H:%M:%fZ','now', ?) ORDER BY fired_at DESC",
|
||||||
|
(f"-{int(days)} days",),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{"id": r["id"], "ticker": r["ticker"], "name": r["name"], "kind": r["kind"],
|
||||||
|
"condition": r["condition"], "price": r["price"],
|
||||||
|
"detail": json.loads(r["detail_json"] or "{}"), "fired_at": r["fired_at"]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ from .db import (
|
|||||||
upsert_broker_cash, get_all_broker_cash, delete_broker_cash,
|
upsert_broker_cash, get_all_broker_cash, delete_broker_cash,
|
||||||
upsert_asset_snapshot, get_asset_snapshots,
|
upsert_asset_snapshot, get_asset_snapshots,
|
||||||
add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
|
add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
|
||||||
|
add_watchlist, remove_watchlist, get_watchlist, get_alert_history,
|
||||||
|
get_alert_state_firing, set_alert_firing, touch_alert_seen, add_alert_history,
|
||||||
|
get_alert_last_fired_map, get_ticker_name,
|
||||||
)
|
)
|
||||||
from .scraper import fetch_market_news, fetch_major_indices
|
from .scraper import fetch_market_news, fetch_major_indices
|
||||||
from .price_fetcher import get_current_prices, get_current_prices_detail
|
from .price_fetcher import get_current_prices, get_current_prices_detail
|
||||||
@@ -28,6 +31,10 @@ from .ai_summarizer import summarize_news, OllamaError
|
|||||||
from .auth import verify_webai_key
|
from .auth import verify_webai_key
|
||||||
from . import webai_cache
|
from . import webai_cache
|
||||||
from . import holdings_intel
|
from . import holdings_intel
|
||||||
|
from . import trade_alerts
|
||||||
|
from .trade_alerts import (
|
||||||
|
build_monitor_set, current_session, diff_firing, DEFAULT_EXIT_PARAMS, DEFAULT_BUY_PARAMS,
|
||||||
|
)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
install_access_log(app)
|
install_access_log(app)
|
||||||
@@ -506,6 +513,90 @@ def get_webai_news_sentiment(date: str | None = None):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/webai/trade-alert/monitor-set", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def get_trade_alert_monitor_set():
|
||||||
|
"""web-ai(Windows 워커) 전용 — 실시간 매매 알람 감시대상 조립 (계약 §5.1).
|
||||||
|
|
||||||
|
session은 KST 시각으로 pre/regular/after 판정 후, 평일·휴장 여부(is_market_open)를
|
||||||
|
함께 게이팅해 최종 closed 여부를 결정한다.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
kst = timezone(timedelta(hours=9))
|
||||||
|
now_kst = datetime.now(kst)
|
||||||
|
session = current_session(now_kst)
|
||||||
|
if not is_market_open(now_kst.date()):
|
||||||
|
session = "closed"
|
||||||
|
|
||||||
|
from .db import _conn
|
||||||
|
conn = _conn()
|
||||||
|
try:
|
||||||
|
return build_monitor_set(conn, session, DEFAULT_EXIT_PARAMS, DEFAULT_BUY_PARAMS)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TradeAlertReport(BaseModel):
|
||||||
|
as_of: str | None = None
|
||||||
|
firing: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/webai/trade-alert/report", dependencies=[Depends(verify_webai_key)])
|
||||||
|
def post_trade_alert_report(req: TradeAlertReport):
|
||||||
|
"""web-ai(Windows 워커) 전용 — 발화 보고 수신 (계약 §5.2).
|
||||||
|
|
||||||
|
직전 발화상태 대비 edge diff(diff_firing) 후, 신규 alert는
|
||||||
|
agent-office 전송 성공 시에만 상태(firing=True)+이력 반영한다.
|
||||||
|
전송 실패 시 상태를 채택하지 않아 다음 사이클에 동일 alert가 다시
|
||||||
|
"신규"로 잡혀 재시도된다(멱등). 해제(cleared)는 전송과 무관하게 firing=False.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
cooldown_h = float(os.getenv("TRADE_ALERT_COOLDOWN_HOURS", "6"))
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
prev = get_alert_state_firing()
|
||||||
|
last_fired = get_alert_last_fired_map()
|
||||||
|
d = diff_firing(req.firing, prev)
|
||||||
|
|
||||||
|
new_count = 0
|
||||||
|
suppressed = 0
|
||||||
|
for a in d["new"]:
|
||||||
|
key = (a["ticker"], a["kind"], a["condition"])
|
||||||
|
# 쿨다운: 같은 종목·조건이 최근 발동됐으면(해제→재발화 오실레이션) 재알림 억제
|
||||||
|
lf = last_fired.get(key)
|
||||||
|
if cooldown_h > 0 and _within_cooldown(now, lf, timedelta(hours=cooldown_h)):
|
||||||
|
set_alert_firing(*key, firing=True, mark_fired=False) # firing 유지, 발동시각 미갱신
|
||||||
|
suppressed += 1
|
||||||
|
continue
|
||||||
|
name = a.get("name") or get_ticker_name(a["ticker"])
|
||||||
|
alert = {**a, "name": name}
|
||||||
|
if trade_alerts.notify_agent_office([alert]):
|
||||||
|
set_alert_firing(*key, firing=True) # 발동시각 갱신(UTC)
|
||||||
|
add_alert_history(
|
||||||
|
a["ticker"], name, a["kind"], a["condition"],
|
||||||
|
a.get("price"), a.get("detail") or {},
|
||||||
|
)
|
||||||
|
new_count += 1
|
||||||
|
|
||||||
|
for ticker, kind, condition in d["cleared"]:
|
||||||
|
set_alert_firing(ticker, kind, condition, firing=False)
|
||||||
|
|
||||||
|
touch_alert_seen(d["seen"], req.as_of or "")
|
||||||
|
|
||||||
|
return {"new_alerts": new_count, "cleared": len(d["cleared"]), "suppressed": suppressed}
|
||||||
|
|
||||||
|
|
||||||
|
def _within_cooldown(now, last_iso, cooldown) -> bool:
|
||||||
|
"""last_iso(UTC ISO `%Y-%m-%dT%H:%M:%fZ`)가 now 기준 cooldown 이내면 True."""
|
||||||
|
if not last_iso:
|
||||||
|
return False
|
||||||
|
from datetime import datetime
|
||||||
|
try:
|
||||||
|
lf = datetime.strptime(last_iso, "%Y-%m-%dT%H:%M:%fZ")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
return (now - lf) < cooldown
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/portfolio", status_code=201)
|
@app.post("/api/portfolio", status_code=201)
|
||||||
def create_portfolio_item(req: PortfolioItemRequest):
|
def create_portfolio_item(req: PortfolioItemRequest):
|
||||||
"""포트폴리오 종목 추가"""
|
"""포트폴리오 종목 추가"""
|
||||||
@@ -653,6 +744,44 @@ def remove_sell_history(record_id: int):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Watchlist & Trade Alerts API (실시간 매매 알람) ---
|
||||||
|
|
||||||
|
class WatchlistItemRequest(BaseModel):
|
||||||
|
ticker: str
|
||||||
|
name: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stock/watchlist")
|
||||||
|
def list_watchlist():
|
||||||
|
"""관심종목 목록 조회"""
|
||||||
|
return {"watchlist": get_watchlist()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/stock/watchlist", status_code=201)
|
||||||
|
def create_watchlist_item(req: WatchlistItemRequest):
|
||||||
|
"""관심종목 추가 (이미 존재하면 name/note 갱신, 멱등)"""
|
||||||
|
add_watchlist(req.ticker, req.name, req.note)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/stock/watchlist/{ticker}")
|
||||||
|
def delete_watchlist_item(ticker: str):
|
||||||
|
"""관심종목 삭제"""
|
||||||
|
if not remove_watchlist(ticker):
|
||||||
|
raise HTTPException(status_code=404, detail="not in watchlist")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stock/trade-alerts")
|
||||||
|
def list_trade_alerts(days: int = 7):
|
||||||
|
"""매매 알람 이력 조회 (최근 N일). 조건별 근거(reason) 문자열 포함 — FE가 detail 객체 대신 렌더."""
|
||||||
|
alerts = get_alert_history(days)
|
||||||
|
for a in alerts:
|
||||||
|
a["reason"] = trade_alerts.condition_reason(a.get("condition", ""))
|
||||||
|
return {"alerts": alerts}
|
||||||
|
|
||||||
|
|
||||||
# --- Holdings Intelligence API ---
|
# --- Holdings Intelligence API ---
|
||||||
|
|
||||||
@app.get("/api/stock/holdings/intel")
|
@app.get("/api/stock/holdings/intel")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -59,13 +60,19 @@ async def score_sentiment(
|
|||||||
*,
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
model: str = DEFAULT_MODEL,
|
model: str = DEFAULT_MODEL,
|
||||||
|
asof: dt.date | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Returns {ticker, score_raw, reason, news_count, tokens_input, tokens_output, model}."""
|
"""Returns {ticker, score_raw, reason, news_count, tokens_input, tokens_output, model}.
|
||||||
|
|
||||||
|
asof(현재 KST 일자)를 주면 prompt 맨 앞에 오늘 날짜를 명시해 LLM이 현재 시점 기준으로 판단한다.
|
||||||
|
"""
|
||||||
news_block = _format_news_block(news)
|
news_block = _format_news_block(news)
|
||||||
prompt = PROMPT_TEMPLATE.format(
|
prompt = PROMPT_TEMPLATE.format(
|
||||||
name=name or ticker, ticker=ticker,
|
name=name or ticker, ticker=ticker,
|
||||||
n=len(news), news_block=news_block,
|
n=len(news), news_block=news_block,
|
||||||
)
|
)
|
||||||
|
if asof is not None:
|
||||||
|
prompt = f"오늘 날짜: {asof.isoformat()} (이 시점 기준으로 뉴스를 평가하세요)\n\n" + prompt
|
||||||
resp = await llm.messages.create(
|
resp = await llm.messages.create(
|
||||||
model=model,
|
model=model,
|
||||||
max_tokens=200,
|
max_tokens=200,
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ def _make_llm():
|
|||||||
|
|
||||||
async def _process_one(
|
async def _process_one(
|
||||||
ticker: str, name: str, articles: List[Dict[str, Any]],
|
ticker: str, name: str, articles: List[Dict[str, Any]],
|
||||||
sem: asyncio.Semaphore, llm, model: str,
|
sem: asyncio.Semaphore, llm, model: str, asof: dt.date,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
async with sem:
|
async with sem:
|
||||||
return await _analyzer.score_sentiment(
|
return await _analyzer.score_sentiment(
|
||||||
llm, ticker, articles, name=name, model=model,
|
llm, ticker, articles, name=name, model=model, asof=asof,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ async def refresh_daily(
|
|||||||
arts = articles_by_ticker.get(t, [])
|
arts = articles_by_ticker.get(t, [])
|
||||||
if not arts:
|
if not arts:
|
||||||
continue # 매핑 0 — score 미생성
|
continue # 매핑 0 — score 미생성
|
||||||
tasks.append(_process_one(t, name_map.get(t, t), arts, sem, llm, model))
|
tasks.append(_process_one(t, name_map.get(t, t), arts, sem, llm, model, asof))
|
||||||
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
raw_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
successes: List[Dict[str, Any]] = []
|
successes: List[Dict[str, Any]] = []
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ from . import telegram as _tg
|
|||||||
from .engine import Screener, ScreenContext
|
from .engine import Screener, ScreenContext
|
||||||
|
|
||||||
|
|
||||||
|
def _today_kst() -> dt.date:
|
||||||
|
"""KST 오늘 날짜.
|
||||||
|
|
||||||
|
stock 컨테이너는 python:3.12-alpine + tzdata 미설치라 TZ=Asia/Seoul이 무효 →
|
||||||
|
date.today()가 UTC를 반환한다. 08시대(KST) 리포트가 하루 밀리는 것을 막기 위해
|
||||||
|
UTC+9로 명시 보정한다(holdings_intel._today_kst와 동일한 관용).
|
||||||
|
"""
|
||||||
|
return (dt.datetime.utcnow() + dt.timedelta(hours=9)).date()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_asof(asof_str, conn: sqlite3.Connection) -> dt.date:
|
def _resolve_asof(asof_str, conn: sqlite3.Connection) -> dt.date:
|
||||||
if asof_str:
|
if asof_str:
|
||||||
return dt.date.fromisoformat(asof_str)
|
return dt.date.fromisoformat(asof_str)
|
||||||
@@ -263,7 +273,7 @@ from . import snapshot as _snap
|
|||||||
|
|
||||||
@router.post("/snapshot/refresh")
|
@router.post("/snapshot/refresh")
|
||||||
def post_snapshot_refresh(asof: Optional[str] = None):
|
def post_snapshot_refresh(asof: Optional[str] = None):
|
||||||
asof_date = dt.date.fromisoformat(asof) if asof else dt.date.today()
|
asof_date = dt.date.fromisoformat(asof) if asof else _today_kst()
|
||||||
if asof_date.weekday() >= 5:
|
if asof_date.weekday() >= 5:
|
||||||
return {"asof": asof_date.isoformat(), "status": "skipped_weekend"}
|
return {"asof": asof_date.isoformat(), "status": "skipped_weekend"}
|
||||||
with _conn() as c:
|
with _conn() as c:
|
||||||
@@ -300,7 +310,7 @@ from .ai_news import validation as _ai_validation
|
|||||||
|
|
||||||
@router.post("/snapshot/refresh-news-sentiment")
|
@router.post("/snapshot/refresh-news-sentiment")
|
||||||
async def post_refresh_news_sentiment(asof: Optional[str] = None):
|
async def post_refresh_news_sentiment(asof: Optional[str] = None):
|
||||||
asof_date = dt.date.fromisoformat(asof) if asof else dt.date.today()
|
asof_date = dt.date.fromisoformat(asof) if asof else _today_kst()
|
||||||
if asof_date.weekday() >= 5:
|
if asof_date.weekday() >= 5:
|
||||||
return {"asof": asof_date.isoformat(), "status": "skipped_weekend"}
|
return {"asof": asof_date.isoformat(), "status": "skipped_weekend"}
|
||||||
if _is_holiday(asof_date):
|
if _is_holiday(asof_date):
|
||||||
|
|||||||
156
stock/app/trade_alerts.py
Normal file
156
stock/app/trade_alerts.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""매매 알람 — 감시대상(monitor-set) 조립. 순수 조립 로직(HTTP/텔레그램 없음).
|
||||||
|
|
||||||
|
계약 §5.1 (docs/superpowers/specs/2026-07-02-realtime-trade-alerts-design.md) —
|
||||||
|
Windows 워커가 GET /api/webai/trade-alert/monitor-set 로 받는 응답을 조립한다.
|
||||||
|
NAS는 watchlist ∪ screener 최신 성공 run 후보를 buy_targets로, 보유 종목을
|
||||||
|
sell_targets로 병합해 넘긴다. TA/조건판정은 워커 쪽 책임.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone, time as _time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.db import get_all_portfolio, get_watchlist
|
||||||
|
|
||||||
|
_KST = timezone(timedelta(hours=9))
|
||||||
|
|
||||||
|
# KST 세션 창(시:분) — 평일+휴장 판정은 호출부에서 is_market_open으로 별도 게이팅
|
||||||
|
_SESSIONS = [
|
||||||
|
("pre", (8, 30), (9, 0)),
|
||||||
|
("regular", (9, 0), (15, 30)),
|
||||||
|
("after", (16, 0), (18, 0)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def current_session(now_kst) -> str:
|
||||||
|
"""now_kst의 time만으로 pre/regular/after/closed 세션 판정 (요일·휴장 무관)."""
|
||||||
|
t = now_kst.time()
|
||||||
|
for name, (sh, sm), (eh, em) in _SESSIONS:
|
||||||
|
if _time(sh, sm) <= t < _time(eh, em):
|
||||||
|
return name
|
||||||
|
return "closed"
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_EXIT_PARAMS = {"stop_pct": 0.08, "take_pct": 0.25, "trailing_pct": 0.10,
|
||||||
|
"climax_vol_x": 3.0, "climax_close_pct": 0.97}
|
||||||
|
DEFAULT_BUY_PARAMS = {"rsi_oversold": 30, "breakout_vol_mult": 1.5, "pullback_pct": 0.02}
|
||||||
|
|
||||||
|
# 조건별 "왜 매수/매도인가" 한 줄 근거 — agent-office `telegram_trade._COND_REASON`과 동일 유지(sync).
|
||||||
|
# API(/api/stock/trade-alerts)가 이 문자열을 반환해 FE가 detail(객체) 대신 안전하게 렌더한다.
|
||||||
|
_COND_REASON = {
|
||||||
|
"buy_ma20_pullback": "상승추세 중 MA20 지지선 눌림목 반등 — 저가 진입 기회",
|
||||||
|
"buy_breakout": "전고점·저항 돌파 + 거래량 증가 — 추세 상승 진입 신호",
|
||||||
|
"buy_rsi_bounce": "RSI 과매도(30↓)에서 반등 — 단기 낙폭과대 되돌림",
|
||||||
|
"sell_stop_loss": "평단 대비 손절선 도달 — 추가 하락 리스크 차단",
|
||||||
|
"sell_ma_break": "주요 이평선(MA50/200) 이탈 — 추세 훼손, 보유 재검토",
|
||||||
|
"sell_take_profit": "목표 수익 도달 — 이익 실현 구간",
|
||||||
|
"sell_climax": "거래량 급증 + 윗꼬리(고점 대비 하락 마감) — 분산·소진 의심",
|
||||||
|
"sell_trailing_stop":"보유기간 고점 대비 하락 — 수익 반납 방어(트레일링 스톱)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def condition_reason(condition: str) -> str:
|
||||||
|
"""조건 키 → 사람이 읽는 근거 한 줄. 미지의 조건이면 조건 키 그대로."""
|
||||||
|
return _COND_REASON.get(condition, condition)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_screener_candidates(conn) -> list:
|
||||||
|
"""최신 성공(status='success') screener run의 후보 {ticker,name} 목록."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM screener_runs WHERE status='success' ORDER BY asof DESC, id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return []
|
||||||
|
run_id = row[0]
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT ticker, name FROM screener_results WHERE run_id=? ORDER BY rank", (run_id,)
|
||||||
|
).fetchall()
|
||||||
|
return [{"ticker": r[0], "name": r[1]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def holding_high(conn, ticker: str, lookback_days: int = 60) -> Optional[float]:
|
||||||
|
"""보유기간 고점(트레일링 스톱용) — krx_daily_prices 최근 lookback_days 최고 high."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT MAX(high) FROM krx_daily_prices WHERE ticker=? "
|
||||||
|
"AND date >= date('now', ?)",
|
||||||
|
(ticker, f"-{int(lookback_days)} days"),
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row and row[0] is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_monitor_set(conn, session: str, exit_params: dict, buy_params: dict) -> dict:
|
||||||
|
"""계약 §5.1 monitor-set 응답 dict 조립.
|
||||||
|
|
||||||
|
buy_targets = watchlist ∪ 최신 screener 후보 (ticker 기준 중복 제거, watchlist 우선)
|
||||||
|
sell_targets = 보유 종목(portfolio) + avg_price/qty/holding_high
|
||||||
|
"""
|
||||||
|
buy: dict[str, dict] = {}
|
||||||
|
for w in get_watchlist():
|
||||||
|
buy[w["ticker"]] = {
|
||||||
|
"ticker": w["ticker"], "name": w["name"],
|
||||||
|
"source": "watch", "params": w.get("params") or {},
|
||||||
|
}
|
||||||
|
for c in latest_screener_candidates(conn):
|
||||||
|
if c["ticker"] not in buy:
|
||||||
|
buy[c["ticker"]] = {
|
||||||
|
"ticker": c["ticker"], "name": c["name"],
|
||||||
|
"source": "screener", "params": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
sell_targets = []
|
||||||
|
for p in get_all_portfolio():
|
||||||
|
ticker = p["ticker"]
|
||||||
|
sell_targets.append({
|
||||||
|
"ticker": ticker,
|
||||||
|
"name": p.get("name"),
|
||||||
|
"avg_price": p.get("avg_price"),
|
||||||
|
"qty": p.get("quantity"),
|
||||||
|
"holding_high": holding_high(conn, ticker),
|
||||||
|
"params": {},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session": session,
|
||||||
|
"as_of": datetime.now(_KST).isoformat(),
|
||||||
|
"buy_targets": list(buy.values()),
|
||||||
|
"sell_targets": sell_targets,
|
||||||
|
"buy_params": buy_params,
|
||||||
|
"exit_params": exit_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def diff_firing(reported: list, prev: set) -> dict:
|
||||||
|
"""워커 발화집합(reported) vs 직전 발화상태(prev) edge diff.
|
||||||
|
|
||||||
|
reported 각 항목: {ticker,kind,condition,price,detail,name?}.
|
||||||
|
key = (ticker,kind,condition).
|
||||||
|
반환 {"new":[신규 alert...], "cleared":[해제 key...], "seen":[현재 key...]}.
|
||||||
|
"""
|
||||||
|
cur = {}
|
||||||
|
for a in reported:
|
||||||
|
key = (a["ticker"], a["kind"], a["condition"])
|
||||||
|
cur[key] = a
|
||||||
|
cur_keys = set(cur.keys())
|
||||||
|
new_keys = cur_keys - prev
|
||||||
|
cleared = sorted(prev - cur_keys)
|
||||||
|
return {
|
||||||
|
"new": [cur[k] for k in cur_keys if k in new_keys],
|
||||||
|
"cleared": cleared,
|
||||||
|
"seen": sorted(cur_keys),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def notify_agent_office(alerts: list) -> bool:
|
||||||
|
"""신규 alert들을 agent-office로 push (계약 §5.2). 전송 성공 시 True.
|
||||||
|
|
||||||
|
실패(네트워크 오류/비-200)는 False — 호출부가 상태/이력 미채택 후 다음
|
||||||
|
사이클에 동일 alert를 재시도하도록 한다(멱등, at-least-once).
|
||||||
|
"""
|
||||||
|
url = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000") + "/api/agent-office/stock/trade-alert"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10) as c:
|
||||||
|
resp = c.post(url, json={"alerts": alerts})
|
||||||
|
return resp.status_code == 200
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return False
|
||||||
@@ -58,6 +58,18 @@ async def test_score_sentiment_clamps_negative_out_of_range():
|
|||||||
assert out["score_raw"] == -10.0
|
assert out["score_raw"] == -10.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_score_sentiment_includes_asof_date_in_prompt():
|
||||||
|
"""asof(현재 KST 일자)를 넘기면 prompt에 오늘 날짜가 포함되어 LLM이 현재 일자 기준으로 판단."""
|
||||||
|
import datetime as _dt
|
||||||
|
llm = _mk_llm(json.dumps({"score": 5.0, "reason": "ok"}))
|
||||||
|
await analyzer.score_sentiment(
|
||||||
|
llm, "005930", NEWS, name="삼성전자", asof=_dt.date(2026, 7, 2),
|
||||||
|
)
|
||||||
|
user_msg = llm.messages.create.call_args.kwargs["messages"][0]["content"]
|
||||||
|
assert "2026-07-02" in user_msg
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_score_sentiment_includes_summary_in_prompt():
|
async def test_score_sentiment_includes_summary_in_prompt():
|
||||||
"""summary 가 있으면 prompt 에 포함, 없으면 title 만."""
|
"""summary 가 있으면 prompt 에 포함, 없으면 title 만."""
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async def test_refresh_daily_happy_path(conn):
|
|||||||
scores_by_ticker = {
|
scores_by_ticker = {
|
||||||
"005930": 7.5, "000660": 4.0, "373220": -6.0,
|
"005930": 7.5, "000660": 4.0, "373220": -6.0,
|
||||||
}
|
}
|
||||||
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
async def fake_score(llm, ticker, news, *, name=None, model="m", asof=None):
|
||||||
return {
|
return {
|
||||||
"ticker": ticker, "score_raw": scores_by_ticker[ticker],
|
"ticker": ticker, "score_raw": scores_by_ticker[ticker],
|
||||||
"reason": f"r{ticker}", "news_count": 1,
|
"reason": f"r{ticker}", "news_count": 1,
|
||||||
@@ -81,7 +81,7 @@ async def test_refresh_daily_failures_isolated(conn):
|
|||||||
}
|
}
|
||||||
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
||||||
|
|
||||||
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
async def fake_score(llm, ticker, news, *, name=None, model="m", asof=None):
|
||||||
if ticker == "000660":
|
if ticker == "000660":
|
||||||
raise RuntimeError("llm exploded")
|
raise RuntimeError("llm exploded")
|
||||||
return {
|
return {
|
||||||
@@ -116,7 +116,7 @@ async def test_refresh_daily_no_match_ticker_skipped(conn):
|
|||||||
}
|
}
|
||||||
fake_stats = {"total_articles": 1, "matched_pairs": 1, "hit_tickers": 1}
|
fake_stats = {"total_articles": 1, "matched_pairs": 1, "hit_tickers": 1}
|
||||||
|
|
||||||
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
async def fake_score(llm, ticker, news, *, name=None, model="m", asof=None):
|
||||||
return {
|
return {
|
||||||
"ticker": ticker, "score_raw": 5.0, "reason": "r",
|
"ticker": ticker, "score_raw": 5.0, "reason": "r",
|
||||||
"news_count": 1, "tokens_input": 100, "tokens_output": 20,
|
"news_count": 1, "tokens_input": 100, "tokens_output": 20,
|
||||||
@@ -152,7 +152,7 @@ async def test_refresh_daily_sign_gate_no_positive_in_neg(conn):
|
|||||||
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
||||||
scores = {"005930": 6.0, "000660": 2.0, "373220": 0.5} # 모두 양수
|
scores = {"005930": 6.0, "000660": 2.0, "373220": 0.5} # 모두 양수
|
||||||
|
|
||||||
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
async def fake_score(llm, ticker, news, *, name=None, model="m", asof=None):
|
||||||
return {
|
return {
|
||||||
"ticker": ticker, "score_raw": scores[ticker], "reason": "r",
|
"ticker": ticker, "score_raw": scores[ticker], "reason": "r",
|
||||||
"news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model,
|
"news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model,
|
||||||
@@ -183,7 +183,7 @@ async def test_refresh_daily_sign_gate_excludes_neutral(conn):
|
|||||||
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
||||||
scores = {"005930": 3.0, "000660": 0.0, "373220": -3.0}
|
scores = {"005930": 3.0, "000660": 0.0, "373220": -3.0}
|
||||||
|
|
||||||
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
async def fake_score(llm, ticker, news, *, name=None, model="m", asof=None):
|
||||||
return {
|
return {
|
||||||
"ticker": ticker, "score_raw": scores[ticker], "reason": "r",
|
"ticker": ticker, "score_raw": scores[ticker], "reason": "r",
|
||||||
"news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model,
|
"news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model,
|
||||||
|
|||||||
@@ -5,6 +5,21 @@ from fastapi.testclient import TestClient
|
|||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_kst_uses_kst_offset_not_utc(monkeypatch):
|
||||||
|
"""컨테이너가 UTC(Alpine, tzdata 미설치)라 date.today()는 08시 KST에 어제를 준다.
|
||||||
|
_today_kst()는 UTC+9로 보정해 오늘(KST)을 반환해야 한다."""
|
||||||
|
from app.screener import router
|
||||||
|
|
||||||
|
class _FrozenDT(dt.datetime):
|
||||||
|
@classmethod
|
||||||
|
def utcnow(cls):
|
||||||
|
# 2026-07-01 23:30 UTC == 2026-07-02 08:30 KST (AI 뉴스 리포트 시각대)
|
||||||
|
return dt.datetime(2026, 7, 1, 23, 30, 0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(router.dt, "datetime", _FrozenDT)
|
||||||
|
assert router._today_kst() == dt.date(2026, 7, 2)
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_news_sentiment_weekend_skip():
|
def test_refresh_news_sentiment_weekend_skip():
|
||||||
# 2026-05-16 = Saturday
|
# 2026-05-16 = Saturday
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|||||||
48
stock/tests/test_trade_alerts_db.py
Normal file
48
stock/tests/test_trade_alerts_db.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import os, sqlite3, tempfile, datetime as dt
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(monkeypatch, tmp_path):
|
||||||
|
from app import db as _db
|
||||||
|
monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db"))
|
||||||
|
_db.init_db()
|
||||||
|
return _db
|
||||||
|
|
||||||
|
def test_watchlist_add_get_remove(db):
|
||||||
|
db.add_watchlist("005930", "삼성전자", note="관심")
|
||||||
|
db.add_watchlist("005930", "삼성전자") # 멱등
|
||||||
|
wl = db.get_watchlist()
|
||||||
|
assert [w["ticker"] for w in wl] == ["005930"]
|
||||||
|
assert wl[0]["name"] == "삼성전자"
|
||||||
|
assert db.remove_watchlist("005930") is True
|
||||||
|
assert db.get_watchlist() == []
|
||||||
|
|
||||||
|
def test_alert_state_edge_firing_and_clear(db):
|
||||||
|
key = ("005930", "buy", "buy_breakout")
|
||||||
|
assert db.get_alert_state_firing() == set()
|
||||||
|
db.set_alert_firing(*key, firing=True, at_iso="2026-07-02T00:01:00Z")
|
||||||
|
assert key in db.get_alert_state_firing()
|
||||||
|
db.set_alert_firing(*key, firing=False)
|
||||||
|
assert key not in db.get_alert_state_firing()
|
||||||
|
|
||||||
|
def test_alert_history_records_and_reads(db):
|
||||||
|
db.add_alert_history("005930", "삼성전자", "buy", "buy_breakout", 71500, {"vol": 2.1})
|
||||||
|
rows = db.get_alert_history(days=7)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["ticker"] == "005930" and rows[0]["kind"] == "buy"
|
||||||
|
assert rows[0]["detail"]["vol"] == 2.1
|
||||||
|
|
||||||
|
def test_alert_history_days_filter_format_consistency(db):
|
||||||
|
"""fired_at은 ISO(T/Z)로 저장 — 필터도 ISO여야 경계일 비교가 정확.
|
||||||
|
7일 경계 밖(정확히 7일 전 자정) 레코드는 제외되어야 한다. 포맷 불일치면 잘못 포함됨."""
|
||||||
|
db.add_alert_history("005930", "삼성", "buy", "buy_breakout", 71500, {}) # now
|
||||||
|
conn = sqlite3.connect(db.DB_PATH)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO trade_alert_history(ticker,name,kind,condition,price,detail_json,fired_at) "
|
||||||
|
"VALUES('000660','SK','sell','sell_stop_loss',60000,'{}', "
|
||||||
|
"strftime('%Y-%m-%dT%H:%M:%fZ','now','-7 days','start of day'))"
|
||||||
|
)
|
||||||
|
conn.commit(); conn.close()
|
||||||
|
tickers = [r["ticker"] for r in db.get_alert_history(days=7)]
|
||||||
|
assert "005930" in tickers
|
||||||
|
assert "000660" not in tickers
|
||||||
18
stock/tests/test_trade_alerts_edge.py
Normal file
18
stock/tests/test_trade_alerts_edge.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
def test_diff_new_and_cleared_and_rearm():
|
||||||
|
from app.trade_alerts import diff_firing
|
||||||
|
reported = [{"ticker": "005930", "kind": "buy", "condition": "buy_breakout",
|
||||||
|
"price": 71500, "detail": {}}]
|
||||||
|
# 최초: prev 비어있음 → 신규
|
||||||
|
d1 = diff_firing(reported, prev=set())
|
||||||
|
assert [a["condition"] for a in d1["new"]] == ["buy_breakout"]
|
||||||
|
assert d1["cleared"] == []
|
||||||
|
# 유지: prev에 이미 있음 → 신규 없음
|
||||||
|
prev = {("005930", "buy", "buy_breakout")}
|
||||||
|
d2 = diff_firing(reported, prev=prev)
|
||||||
|
assert d2["new"] == []
|
||||||
|
# 해제: reported 비었고 prev에 있음 → cleared
|
||||||
|
d3 = diff_firing([], prev=prev)
|
||||||
|
assert d3["cleared"] == [("005930", "buy", "buy_breakout")]
|
||||||
|
# 재무장 후 재발화: prev 다시 비면 신규
|
||||||
|
d4 = diff_firing(reported, prev=set())
|
||||||
|
assert len(d4["new"]) == 1
|
||||||
101
stock/tests/test_trade_alerts_monitorset.py
Normal file
101
stock/tests/test_trade_alerts_monitorset.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import sqlite3
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn(monkeypatch, tmp_path):
|
||||||
|
from app import db as _db
|
||||||
|
monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db"))
|
||||||
|
_db.init_db()
|
||||||
|
c = sqlite3.connect(_db.DB_PATH)
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
# 보유 1종목 (add_portfolio_item 실제 시그니처: broker/ticker/name/quantity/avg_price — market 파라미터 없음)
|
||||||
|
_db.add_portfolio_item(ticker="000660", name="SK하이닉스", quantity=10,
|
||||||
|
avg_price=180000, broker="kis")
|
||||||
|
# watchlist 1종목
|
||||||
|
_db.add_watchlist("005930", "삼성전자")
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_monitor_set_merges_sources(conn):
|
||||||
|
from app import trade_alerts as ta
|
||||||
|
ms = ta.build_monitor_set(conn, session="regular",
|
||||||
|
exit_params={"stop_pct": 0.08}, buy_params={"rsi_oversold": 30})
|
||||||
|
buy_tickers = {t["ticker"] for t in ms["buy_targets"]}
|
||||||
|
sell_tickers = {t["ticker"] for t in ms["sell_targets"]}
|
||||||
|
assert "005930" in buy_tickers # watchlist
|
||||||
|
assert "000660" in sell_tickers # 보유
|
||||||
|
assert ms["session"] == "regular"
|
||||||
|
assert ms["exit_params"]["stop_pct"] == 0.08
|
||||||
|
sell = next(t for t in ms["sell_targets"] if t["ticker"] == "000660")
|
||||||
|
assert sell["avg_price"] == 180000 and sell["qty"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_screener_candidates_empty_when_no_run(conn):
|
||||||
|
from app import trade_alerts as ta
|
||||||
|
assert ta.latest_screener_candidates(conn) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_screener_candidates_picks_latest_success_run(conn):
|
||||||
|
from app import trade_alerts as ta
|
||||||
|
now = "2026-07-02T09:00:00Z"
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO screener_runs (asof, mode, status, started_at, weights_json, "
|
||||||
|
"node_params_json, gate_params_json, top_n) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
(now, "manual", "failed", now, "{}", "{}", "{}", 20),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO screener_runs (asof, mode, status, started_at, weights_json, "
|
||||||
|
"node_params_json, gate_params_json, top_n) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
(now, "manual", "success", now, "{}", "{}", "{}", 20),
|
||||||
|
)
|
||||||
|
run_id = conn.execute("SELECT id FROM screener_runs WHERE status='success'").fetchone()[0]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO screener_results (run_id, rank, ticker, name, total_score, scores_json) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(run_id, 1, "035720", "카카오", 88.5, "{}"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
candidates = ta.latest_screener_candidates(conn)
|
||||||
|
assert candidates == [{"ticker": "035720", "name": "카카오"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_holding_high_returns_max_high_within_lookback(conn):
|
||||||
|
from app import trade_alerts as ta
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO krx_daily_prices (ticker, date, high) VALUES (?,?,?)",
|
||||||
|
("000660", "2026-06-01", 200000),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO krx_daily_prices (ticker, date, high) VALUES (?,?,?)",
|
||||||
|
("000660", "2026-06-20", 210000),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
assert ta.holding_high(conn, "000660", lookback_days=60) == 210000
|
||||||
|
|
||||||
|
|
||||||
|
def test_holding_high_none_when_no_price_history(conn):
|
||||||
|
from app import trade_alerts as ta
|
||||||
|
assert ta.holding_high(conn, "999999") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_monitor_set_dedupes_watchlist_and_screener_overlap(conn):
|
||||||
|
from app import trade_alerts as ta
|
||||||
|
now = "2026-07-02T09:00:00Z"
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO screener_runs (asof, mode, status, started_at, weights_json, "
|
||||||
|
"node_params_json, gate_params_json, top_n) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
(now, "manual", "success", now, "{}", "{}", "{}", 20),
|
||||||
|
)
|
||||||
|
run_id = cur.lastrowid
|
||||||
|
# 스크리너 후보가 watchlist와 중복(005930)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO screener_results (run_id, rank, ticker, name, total_score, scores_json) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(run_id, 1, "005930", "삼성전자", 90.0, "{}"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
ms = ta.build_monitor_set(conn, session="regular", exit_params={}, buy_params={})
|
||||||
|
buy_tickers = [t["ticker"] for t in ms["buy_targets"]]
|
||||||
|
assert buy_tickers.count("005930") == 1
|
||||||
43
stock/tests/test_trade_alerts_monitorset_api.py
Normal file
43
stock/tests/test_trade_alerts_monitorset_api.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import datetime as dt
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_session_windows():
|
||||||
|
from app.trade_alerts import current_session
|
||||||
|
d = dt.date(2026, 7, 2)
|
||||||
|
assert current_session(dt.datetime.combine(d, dt.time(8, 40))) == "pre"
|
||||||
|
assert current_session(dt.datetime.combine(d, dt.time(10, 0))) == "regular"
|
||||||
|
assert current_session(dt.datetime.combine(d, dt.time(17, 0))) == "after"
|
||||||
|
assert current_session(dt.datetime.combine(d, dt.time(20, 0))) == "closed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, tmp_path):
|
||||||
|
from app import db as _db
|
||||||
|
monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db"))
|
||||||
|
_db.init_db()
|
||||||
|
monkeypatch.setenv("WEBAI_API_KEY", "k")
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitor_set_requires_auth(client):
|
||||||
|
assert client.get("/api/webai/trade-alert/monitor-set").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitor_set_ok(client):
|
||||||
|
r = client.get("/api/webai/trade-alert/monitor-set", headers={"X-WebAI-Key": "k"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["session"] in ("pre", "regular", "after", "closed")
|
||||||
|
assert "buy_targets" in body and "sell_targets" in body
|
||||||
|
assert body["exit_params"]["trailing_pct"] == 0.10
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitor_set_exit_params_include_climax(client):
|
||||||
|
"""climax 파라미터 중앙화 — 워커가 하드코딩 대신 NAS exit_params에서 받아 튜닝."""
|
||||||
|
ep = client.get("/api/webai/trade-alert/monitor-set",
|
||||||
|
headers={"X-WebAI-Key": "k"}).json()["exit_params"]
|
||||||
|
assert ep["climax_vol_x"] == 3.0
|
||||||
|
assert ep["climax_close_pct"] == 0.97
|
||||||
96
stock/tests/test_trade_alerts_report_api.py
Normal file
96
stock/tests/test_trade_alerts_report_api.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, tmp_path):
|
||||||
|
from app import db as _db
|
||||||
|
monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db"))
|
||||||
|
_db.init_db()
|
||||||
|
monkeypatch.setenv("WEBAI_API_KEY", "k")
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _report(client, firing):
|
||||||
|
return client.post("/api/webai/trade-alert/report",
|
||||||
|
headers={"X-WebAI-Key": "k"},
|
||||||
|
json={"as_of": "2026-07-02T09:01:00+09:00", "firing": firing})
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_new_edge_sends_and_persists(client):
|
||||||
|
firing = [{"ticker": "005930", "name": "삼성전자", "kind": "buy",
|
||||||
|
"condition": "buy_breakout", "price": 71500, "detail": {"vol": 2.0}}]
|
||||||
|
with patch("app.trade_alerts.notify_agent_office", return_value=True) as m:
|
||||||
|
r1 = _report(client, firing)
|
||||||
|
assert r1.json()["new_alerts"] == 1
|
||||||
|
assert m.called
|
||||||
|
# 2번째 동일 firing → 유지, 신규 0
|
||||||
|
with patch("app.trade_alerts.notify_agent_office", return_value=True):
|
||||||
|
r2 = _report(client, firing)
|
||||||
|
assert r2.json()["new_alerts"] == 0
|
||||||
|
# 이력 1건
|
||||||
|
assert len(client.get("/api/stock/trade-alerts?days=1").json()["alerts"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_send_failure_does_not_persist(client):
|
||||||
|
firing = [{"ticker": "005930", "name": "삼성전자", "kind": "buy",
|
||||||
|
"condition": "buy_breakout", "price": 71500, "detail": {}}]
|
||||||
|
with patch("app.trade_alerts.notify_agent_office", return_value=False):
|
||||||
|
r = _report(client, firing)
|
||||||
|
assert r.json()["new_alerts"] == 0 # 전송 실패 → 미채택
|
||||||
|
# 다음 사이클(전송 성공) 재시도되어 알림
|
||||||
|
with patch("app.trade_alerts.notify_agent_office", return_value=True):
|
||||||
|
r2 = _report(client, firing)
|
||||||
|
assert r2.json()["new_alerts"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_cooldown_suppresses_immediate_refire(client):
|
||||||
|
"""같은 종목·조건이 해제됐다 곧바로 재발화해도 쿨다운(기본 6h) 내면 재알림 억제."""
|
||||||
|
firing = [{"ticker": "005930", "name": "삼성", "kind": "buy",
|
||||||
|
"condition": "buy_breakout", "price": 71500, "detail": {}}]
|
||||||
|
with patch("app.trade_alerts.notify_agent_office", return_value=True):
|
||||||
|
assert _report(client, firing).json()["new_alerts"] == 1 # 최초 알림
|
||||||
|
_report(client, []) # 해제
|
||||||
|
r = _report(client, firing) # 즉시 재발화 → 쿨다운 억제
|
||||||
|
assert r.json()["new_alerts"] == 0
|
||||||
|
assert r.json()["suppressed"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_refire_after_cooldown_alerts(client, monkeypatch):
|
||||||
|
"""쿨다운=0이면 해제 후 재발화 시 재알림."""
|
||||||
|
monkeypatch.setenv("TRADE_ALERT_COOLDOWN_HOURS", "0")
|
||||||
|
firing = [{"ticker": "005930", "name": "삼성", "kind": "buy",
|
||||||
|
"condition": "buy_breakout", "price": 71500, "detail": {}}]
|
||||||
|
with patch("app.trade_alerts.notify_agent_office", return_value=True):
|
||||||
|
_report(client, firing)
|
||||||
|
_report(client, [])
|
||||||
|
r = _report(client, firing)
|
||||||
|
assert r.json()["new_alerts"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_trade_alerts_history_includes_reason_string(client):
|
||||||
|
"""이력 API가 조건별 사람이 읽는 근거(reason) 문자열을 함께 반환한다(FE가 detail 객체 대신 렌더)."""
|
||||||
|
firing = [{"ticker": "005930", "name": "삼성", "kind": "sell",
|
||||||
|
"condition": "sell_ma_break", "price": 60000,
|
||||||
|
"detail": {"ma50": 1, "ma200": 2, "severity": "high"}}]
|
||||||
|
with patch("app.trade_alerts.notify_agent_office", return_value=True):
|
||||||
|
_report(client, firing)
|
||||||
|
alerts = client.get("/api/stock/trade-alerts?days=1").json()["alerts"]
|
||||||
|
assert isinstance(alerts[0]["reason"], str) and len(alerts[0]["reason"]) > 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_resolves_stock_name_from_watchlist(client):
|
||||||
|
"""워커 firing에 name이 없어도 NAS가 종목명을 해석해 알림에 포함한다."""
|
||||||
|
from app import db
|
||||||
|
db.add_watchlist("000660", "SK하이닉스")
|
||||||
|
firing = [{"ticker": "000660", "kind": "buy", "condition": "buy_breakout",
|
||||||
|
"price": 180000, "detail": {}}] # name 없음
|
||||||
|
with patch("app.trade_alerts.notify_agent_office", return_value=True) as m:
|
||||||
|
_report(client, firing)
|
||||||
|
sent_alert = m.call_args[0][0][0]
|
||||||
|
assert sent_alert["name"] == "SK하이닉스"
|
||||||
|
# 이력에도 종목명 기록
|
||||||
|
alerts = client.get("/api/stock/trade-alerts?days=1").json()["alerts"]
|
||||||
|
assert alerts[0]["name"] == "SK하이닉스"
|
||||||
22
stock/tests/test_watchlist_api.py
Normal file
22
stock/tests/test_watchlist_api.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, tmp_path):
|
||||||
|
from app import db as _db
|
||||||
|
monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db"))
|
||||||
|
_db.init_db()
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
def test_watchlist_crud(client):
|
||||||
|
assert client.get("/api/stock/watchlist").json()["watchlist"] == []
|
||||||
|
r = client.post("/api/stock/watchlist", json={"ticker": "005930", "name": "삼성전자"})
|
||||||
|
assert r.status_code == 201
|
||||||
|
wl = client.get("/api/stock/watchlist").json()["watchlist"]
|
||||||
|
assert wl[0]["ticker"] == "005930"
|
||||||
|
assert client.delete("/api/stock/watchlist/005930").status_code == 200
|
||||||
|
assert client.delete("/api/stock/watchlist/005930").status_code == 404
|
||||||
|
|
||||||
|
def test_trade_alerts_history_empty(client):
|
||||||
|
assert client.get("/api/stock/trade-alerts?days=7").json()["alerts"] == []
|
||||||
Reference in New Issue
Block a user