diff --git a/docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md b/docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md new file mode 100644 index 0000000..539dc42 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md @@ -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`) +- ❌ 자치구별 매칭 분포 대시보드 위젯