docs(spec): 청약 타겟팅 고도화 설계

- 수집 사전 좁힘(30일 윈도우) + 완료 공고 90일 grace 자동 정리
- 자치구 5티어 가중치 매칭 (S/A/B/C/D)
- realestate-lab → agent-office push 기반 즉시 텔레그램 알림
- 데일리 리포트 cron 폐기, 임계값 통과 신규 매칭만 푸시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 03:36:38 +09:00
parent 2a8635e9ed
commit eef2e3967e

View File

@@ -0,0 +1,479 @@
# 청약 서비스 타겟팅 고도화 설계
> 대상: `web-backend/realestate-lab/` + `web-backend/agent-office/`
> 후속 별도 스펙: 프론트 자치구 입력 UI(`web-ui`), 청약 가점 vs 커트라인 비교, 서울 외 광역 자치구 파싱
---
## 1. 목표
현재 청약 서비스가 1) 완료된 공고까지 무차별 수집하고, 2) 매칭이 binary라 단지별 의미 있는 점수 차이가 없으며, 3) 데일리 리포트라 "발견 즉시"의 가치를 못 살리는 문제를 해결한다.
### 핵심 변경
- **수집**: 모집공고 30일 이전 + 이미 `완료` 상태인 공고는 저장하지 않음. 90일 경과 완료 공고 자동 정리.
- **단일 SoT**: `user_profile.preferred_regions`를 수집·조회·매칭의 단일 기준점으로 사용 (서울 default).
- **매칭**: 자치구 5티어 가중치(S=100% / A=80% / B=60% / C=40% / D=20%) 도입. 자격 점수 미세 조정.
- **알림**: 데일리 리포트 폐기. "신규 매칭 + 임계값 통과" 즉시 텔레그램 푸시. realestate-lab → agent-office HTTP push.
### 변경하지 않는 것
- 공공데이터 API 엔드포인트 5종 구성
- 매칭 총점 100점 체계
- 텔레그램 봇 토큰·formatter는 agent-office에 단일 보관
- realestate-lab의 09:00 / 00:00 cron 스케줄(기존 그대로 유지, 트리거 로직만 변경)
---
## 2. 아키텍처 변경 개요
### 2.1 변경 포인트
| # | 위치 | 변경 |
|---|------|------|
| 1 | `realestate-lab/collector.py` | API 호출 시 모집공고일 윈도우 사전 적용. 응답 시 `완료` 상태 skip. 자치구 파싱. 90일 경과 완료 공고 정리. |
| 2 | `realestate-lab/db.py` | `user_profile`에 3컬럼, `announcements``district`, `match_results``notified_at` 추가. `delete_old_completed_announcements()` 신규. |
| 3 | `realestate-lab/matcher.py` | 자치구 5티어 가중치 + 자격 점수 재배분. binary → 자치구 그라디언트. |
| 4 | `realestate-lab` 신규 모듈 | `notifier.py`: 임계값 통과 신규 매칭 추출 + agent-office push. `notified_at` 멱등 마킹. |
| 5 | `agent-office/agents/realestate.py` | 데일리 cron 폐기. `on_new_matches(matches)` 신규. 메시지 fmt + 인라인 키보드. |
| 6 | `agent-office/main.py` | `POST /api/agent-office/realestate/notify` 신규 엔드포인트. |
### 2.2 데이터 흐름
```
[09:00 cron] realestate-lab.scheduled_collect()
├─ collect_all()
│ ├─ API 호출 (RCRIT_PBLANC_DE_FROM = today 30일)
│ ├─ 응답 파싱 + district 추출
│ ├─ status='완료' skip → upsert
│ └─ delete_old_completed_announcements(grace_days=90)
├─ run_matching() // 5티어 가중치 적용
└─ notify_new_matches()
├─ SELECT match_results WHERE notified_at IS NULL
│ AND match_score >= profile.min_match_score
│ AND profile.notify_enabled = 1
├─ POST agent-office /api/agent-office/realestate/notify
└─ 성공 → UPDATE notified_at = now()
[agent-office] POST /api/agent-office/realestate/notify
└─ RealestateAgent.on_new_matches(matches)
├─ formatter로 텔레그램 텍스트 + 인라인 키보드 빌드
└─ telegram_bot.send_message()
```
### 2.3 기각된 대안
| 대안 | 기각 사유 |
|------|-----------|
| 매칭 로직을 agent-office에 이식 | 두 서비스에 매칭 코드 복제 → 동기화 부담 |
| 완료 공고 즉시 삭제 | 사용자가 회고 못 함. 90일 grace 채택 |
| agent-office가 realestate-lab을 폴링 | 트래픽 + 지연 |
| realestate-lab이 직접 텔레그램 호출 | 토큰·formatter 분산. 봇 단일 책임 위반 |
| 가격·면적 그라디언트 곡선 | 점수 해석 어려움. binary 유지 (자치구 1축에만 곡선 적용) |
---
## 3. DB 스키마 변경
### 3.1 `user_profile` — 3컬럼 추가
```sql
ALTER TABLE user_profile ADD COLUMN preferred_districts TEXT NOT NULL DEFAULT '{}';
ALTER TABLE user_profile ADD COLUMN min_match_score INTEGER NOT NULL DEFAULT 70;
ALTER TABLE user_profile ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 1;
```
- **`preferred_districts`**: JSON. 5티어 분류.
```json
{"S": ["강남구", "서초구"], "A": ["송파구", "마포구"], "B": [], "C": [], "D": []}
```
모든 티어 비어있으면 자치구 기준 미설정으로 간주 (기존 호환 동작).
- **`min_match_score`**: 알림 트리거 임계값(0~100). 기본 70.
- **`notify_enabled`**: 텔레그램 푸시 ON/OFF. 0이면 알림 전체 차단.
### 3.2 `announcements` — `district` 컬럼 추가
```sql
ALTER TABLE announcements ADD COLUMN district TEXT;
CREATE INDEX IF NOT EXISTS idx_ann_district ON announcements(district);
```
- collector가 응답의 `HSSPLY_ADRES` / `region_name`을 정규식 파싱하여 채움.
- 서울 외 지역, 파싱 실패 → NULL.
### 3.3 `match_results` — `notified_at` 컬럼 추가
```sql
ALTER TABLE match_results ADD COLUMN notified_at TEXT;
```
- NULL이면 미알림. 알림 송신 후 `strftime('%Y-%m-%dT%H:%M:%fZ','now')` 기록.
- 기존 `is_new`(사용자가 UI에서 봤는지)와 의미 분리.
### 3.4 신규 함수
```python
def delete_old_completed_announcements(grace_days: int = 90) -> int:
"""winner_date + grace_days 경과한 status='완료' 공고를 삭제.
winner_date가 NULL인 행은 안전하게 보존(수동 검토 대상).
match_results는 FK CASCADE로 자동 삭제. 삭제된 건수 반환.
"""
```
```python
def get_unnotified_matches(min_score: int) -> list[dict]:
"""notified_at IS NULL AND match_score >= min_score 인 매칭 + 공고 정보 조인 반환."""
```
```python
def mark_matches_notified(match_ids: list[int]) -> None:
"""notified_at = now() 일괄 업데이트."""
```
### 3.5 마이그레이션 패턴
기존 db.py의 `init_db()` 안에서 try/except로 컬럼 존재 여부 검사 후 ALTER (운영 DB 무중단).
---
## 4. collector 변경
### 4.1 모집공고일 윈도우 사전 좁힘
```python
def collect_all() -> dict:
today = date.today()
date_from = (today - timedelta(days=30)).strftime("%Y%m%d")
for detail_ep, model_ep in DETAIL_ENDPOINTS:
rows = _api_call(detail_ep, params={
# 공공데이터 API 파라미터명은 엔드포인트별로 다를 수 있음.
# 구현 시 한국부동산원 API 스펙 확인 후 정확한 키 적용.
"RCRIT_PBLANC_DE_FROM": date_from,
})
# ...
```
> ⚠️ **구현 시 검증 필요**: `ApplyhomeInfoDetailSvc`의 5개 엔드포인트가 모두 모집공고일 필터 파라미터를 지원하지 않을 수 있음. 미지원 시 응답 수신 후 클라이언트 측에서 `parsed["rcrit_date"] < date_from` skip하는 fallback을 적용.
### 4.2 `완료` 상태 skip
```python
parsed = _parse_apt_detail(raw)
parsed["district"] = _extract_district(parsed)
status = compute_status(
parsed.get("receipt_start", ""),
parsed.get("receipt_end", ""),
parsed.get("winner_date", ""),
)
if status == "완료":
continue # DB 자원 절감
# 일정 정보 없는 공고 skip (기존 로직 유지)
has_dates = any(parsed.get(f) for f in (...))
if not has_dates:
continue
upsert_announcement(parsed)
```
### 4.3 자치구 추출
```python
DISTRICT_PATTERN = re.compile(r"(?:서울특별시|서울시|서울)\s+(\S+?(?:구|군))")
def _extract_district(parsed: dict) -> str | None:
for src in (parsed.get("address"), parsed.get("region_name")):
if not src:
continue
m = DISTRICT_PATTERN.search(src)
if m:
return m.group(1)
return None
```
### 4.4 정리 + 매칭 + 알림 트리거
```python
def collect_all() -> dict:
# ... 위 수집 로직
save_collect_log(new_count, total_count)
return {"new_count": new_count, "total_count": total_count}
def scheduled_collect():
"""09:00 cron — 수집 + 정리 + 매칭 + 알림"""
collect_all()
deleted = delete_old_completed_announcements(grace_days=90)
logger.info("정리: %d건 삭제", deleted)
run_matching()
notify_new_matches() # NEW
```
---
## 5. matcher 변경
### 5.1 가중치 재배분 (총 100점 유지)
| 축 | 기존 | 신규 |
|----|------|------|
| 지역 | 30 | **35** (광역 10 + 자치구 가중 0~25) |
| 주택유형 | 10 | 10 |
| 면적 | 15 | 15 |
| 가격 | 15 | 15 |
| 자격 | 30 | **25** |
### 5.2 지역 점수 (35점)
```python
TIER_WEIGHTS = {"S": 1.00, "A": 0.80, "B": 0.60, "C": 0.40, "D": 0.20}
def _region_score(profile: dict, ann: dict) -> tuple[int, list[str]]:
region_name = ann.get("region_name") or ""
district = ann.get("district") or ""
preferred_regions = profile.get("preferred_regions") or []
preferred_districts = profile.get("preferred_districts") or {}
region_match = bool(region_name and any(r in region_name for r in preferred_regions))
if not region_match:
return 0, []
# 자치구 기준 미설정 → 광역만으로 풀 점수 (기존 호환)
has_districts = any(preferred_districts.get(t) for t in TIER_WEIGHTS)
if not has_districts:
return 35, [f"선호 지역 일치: {region_name}"]
score = 10
reasons = [f"광역 일치: {region_name}"]
for tier, weight in TIER_WEIGHTS.items():
if district in (preferred_districts.get(tier) or []):
tier_score = round(25 * weight)
score += tier_score
reasons.append(f"자치구 {tier}티어: {district} (+{tier_score})")
break
return score, reasons
```
### 5.3 자격 점수 (25점)
```python
def _eligibility_score(eligible_types: list[str]) -> int:
if not eligible_types:
return 0
score = 15 # 첫 자격
score += min((len(eligible_types) - 1) * 5, 10) # 추가 자격당 +5, 최대 +10
return score
```
다른 축(주택유형 10, 면적 15, 가격 15)은 기존 binary 로직 유지.
### 5.4 매칭 결과 저장
`run_matching()`은 기존 흐름 유지. `match_results.notified_at`은 손대지 않음 (notifier가 관리).
---
## 6. 알림 흐름
### 6.1 realestate-lab 측 — `notifier.py`
```python
import os
import requests
from .db import get_unnotified_matches, mark_matches_notified, get_profile
AGENT_OFFICE_URL = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000")
def notify_new_matches() -> dict:
profile = get_profile()
if not profile or not profile.get("notify_enabled"):
return {"sent": 0, "skipped": "notify_disabled"}
threshold = profile.get("min_match_score", 70)
matches = get_unnotified_matches(threshold)
if not matches:
return {"sent": 0}
try:
resp = requests.post(
f"{AGENT_OFFICE_URL}/api/agent-office/realestate/notify",
json={"matches": matches},
timeout=15,
)
resp.raise_for_status()
body = resp.json()
sent_ids = body.get("sent_ids", [])
if sent_ids:
mark_matches_notified(sent_ids)
return body
except requests.RequestException as e:
logger.error("알림 push 실패: %s", e)
return {"sent": 0, "error": str(e)}
```
알림 push 실패 시 `notified_at`을 채우지 않아 다음 사이클에서 재시도된다.
### 6.2 agent-office 측 — 신규 엔드포인트
```python
# agent-office/main.py
@app.post("/api/agent-office/realestate/notify")
async def realestate_notify(body: dict):
matches = body.get("matches", [])
agent = registry.get("realestate")
result = await agent.on_new_matches(matches)
return result
```
```python
# agents/realestate.py
async def on_new_matches(self, matches: list[dict]) -> dict:
if not matches:
return {"sent": 0, "sent_ids": []}
text = telegram_formatter.format_realestate_matches(matches)
keyboard = telegram_formatter.build_match_keyboard(matches)
tg = await telegram_bot.send_message(text, reply_markup=keyboard)
if not tg.get("ok"):
return {"sent": 0, "sent_ids": [], "error": tg.get("error")}
sent_ids = [m["id"] for m in matches]
return {"sent": len(matches), "sent_ids": sent_ids, "message_id": tg.get("message_id")}
```
### 6.3 텔레그램 메시지 포맷
**3건 이상 — 묶음 카드**
```
🏢 새 청약 매칭 3건
⭐ 92점 — 디에이치 강남 [S]
📍 서울 강남구 (분양가상한제) · 32~45㎡ · 6.2~9.8억
📅 청약 05/15(수) ~ 05/19(일)
⭐ 78점 — 마포 푸르지오 [A]
📍 서울 마포구 · 59~84㎡ · 8.0~11.5억
📅 청약 05/22(수) ~ 05/26(일)
⭐ 72점 — 송파 데시앙 [A]
📍 서울 송파구 · 39~59㎡ · 5.8~7.9억
📅 청약 05/27(월) ~ 05/30(목)
[전체 보기]
```
**1~2건 — 풀 카드**
```
⭐ 90점 — 디에이치 강남 [S]
📍 서울 강남구 (분양가상한제)
🏠 32~45㎡ · 6.2~9.8억
📅 청약 05/15(수) ~ 05/19(일)
✓ 자격: 일반1순위, 특별-신혼부부
💡 광역 일치 / 자치구 S티어 / 예산 범위 / 자격 2개
[🔖 북마크] [📄 공고 보기]
```
### 6.4 인라인 키보드 콜백
| 버튼 | 콜백 동작 |
|------|-----------|
| `[🔖 북마크]` | `PATCH /api/realestate/announcements/{id}/bookmark` (기존 endpoint) |
| `[📄 공고 보기]` | `pblanc_url` (텔레그램 URL 버튼) |
| `[전체 보기]` | 대시보드 deep link (`/realestate?tab=matches`) |
agent-office의 텔레그램 webhook(`/api/agent-office/telegram/webhook`)이 callback_query를 받아 service_proxy로 realestate-lab API 호출.
### 6.5 기존 RealestateAgent 동작 정리
```python
# agent-office/scheduler.py — 09:15 데일리 cron 제거
# scheduler.add_job(realestate_agent.on_schedule, ...) ← REMOVE
```
`RealestateAgent.on_schedule()`은 호출 지점이 사라지므로 제거. `on_command("fetch_matches")`는 수동 트리거(텔레그램 슬래시 명령)용으로 보존하되 `on_new_matches()`를 직접 호출하도록 단순화.
### 6.6 환경변수
| 변수 | 위치 | 기본값 |
|------|------|--------|
| `AGENT_OFFICE_URL` | realestate-lab `.env` | `http://agent-office:8000` |
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | agent-office (기존) | (기존) |
docker-compose의 사내 네트워크로 호출되므로 외부 노출 없음.
---
## 7. API 변경 요약
### 7.1 realestate-lab
| 메서드 | 경로 | 변경 |
|--------|------|------|
| PUT | `/api/realestate/profile` | body에 `preferred_districts`, `min_match_score`, `notify_enabled` 수용 |
| GET | `/api/realestate/profile` | 응답에 위 3필드 포함 |
| GET | `/api/realestate/announcements` | 응답 item에 `district` 포함 |
| GET | `/api/realestate/announcements/{id}` | 응답에 `district` 포함 |
| GET | `/api/realestate/matches` | 응답 item에 `notified_at` 포함 (디버깅용) |
### 7.2 agent-office
| 메서드 | 경로 | 변경 |
|--------|------|------|
| POST | `/api/agent-office/realestate/notify` | **신규** — realestate-lab 전용 push 수신 |
### 7.3 Pydantic 모델 확장
```python
# realestate-lab/app/models.py
class ProfileUpdate(BaseModel):
# ... 기존 필드
preferred_districts: Optional[Dict[str, List[str]]] = None
min_match_score: Optional[int] = Field(default=None, ge=0, le=100)
notify_enabled: Optional[bool] = None
```
---
## 8. 테스트 전략
| 영역 | 테스트 항목 |
|------|-------------|
| `_extract_district` | "서울특별시 강남구 도곡동" → `"강남구"`, "서울 송파구" → `"송파구"`, "부산 해운대구" → NULL, "" → NULL |
| `compute_status` | 변경 없음. 기존 테스트 유지 |
| `_region_score` | 광역 미매칭 / 광역만 매칭 + 자치구 미설정 / S~D 티어별 / 광역 매칭 + 비선호 자치구 — 5케이스 |
| `_eligibility_score` | 자격 0개 / 1개 / 3개 / 5개 — 점수 단조 증가 + 25 상한 |
| `delete_old_completed_announcements` | winner_date 91일 전 → 삭제, 89일 전 → 보존, status≠'완료' → 보존 |
| collector 사전 좁힘 | mock API 응답으로 30일 윈도우 외 데이터 skip 확인. `완료` skip 확인 |
| `notify_new_matches` 멱등성 | `notified_at` 채워진 매치는 push 후보 제외, push 실패 시 `notified_at` 미기록 → 다음 사이클 재시도 |
| agent-office push endpoint | mock telegram client로 `format_realestate_matches` 호출 + send 검증 |
| 알림 임계값 필터 | min_match_score=70, score=69 → push 대상 외 / score=70 → 포함 |
| `notify_enabled=0` | push 자체 skip |
NAS Docker는 git push 자동 배포이므로 별도 절차 없음. ALTER TABLE은 init_db에서 try/except 패턴으로 운영 DB 무중단 적용.
---
## 9. 스코프
### 본 스펙 범위
- ✅ realestate-lab: collector, matcher, db 변경, notifier 신규
- ✅ agent-office: `/realestate/notify` 엔드포인트, `on_new_matches` 메소드, 메시지 formatter
- ✅ 기존 데일리 RealestateAgent cron 폐기
### 후속 별도 스펙
- ❌ 프론트(`web-ui`) 자치구 5티어 입력 UI (별도 frontend 스펙)
- ❌ 청약 가점 vs 공고별 예상 커트라인 비교 (외부 데이터 의존성, 별도 연구)
- ❌ 서울 외 광역(부산 해운대구 등) 자치구 파싱 확장
- ❌ 매칭 임계값 변경 후 재발송 트리거 (`POST /notifications/resend`)
- ❌ 자치구별 매칭 분포 대시보드 위젯