Files
web-page-backend/docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md
gahusb eef2e3967e 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>
2026-04-28 03:36:38 +09:00

480 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 청약 서비스 타겟팅 고도화 설계
> 대상: `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`)
- ❌ 자치구별 매칭 분포 대시보드 위젯