From c6b969443fadbf11439eb86a175dcf577a2e2f1d Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 13:50:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent-office):=20=EB=A7=A4=EB=AC=BC=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=ED=85=94=EB=A0=88=EA=B7=B8=EB=9E=A8=20not?= =?UTF-8?q?ify-listing(=EB=84=88+=EC=95=84=EB=82=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9 --- agent-office/app/main.py | 18 ++++ .../notifiers/telegram_realestate_listing.py | 83 +++++++++++++++++++ .../tests/test_realestate_listing_notify.py | 78 +++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 agent-office/app/notifiers/telegram_realestate_listing.py create mode 100644 agent-office/tests/test_realestate_listing_notify.py diff --git a/agent-office/app/main.py b/agent-office/app/main.py index cf60454..63444fc 100644 --- a/agent-office/app/main.py +++ b/agent-office/app/main.py @@ -227,6 +227,24 @@ async def realestate_notify(body: RealestateNotifyBody): 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 --- class YouTubeResearchBody(BaseModel): diff --git a/agent-office/app/notifiers/telegram_realestate_listing.py b/agent-office/app/notifiers/telegram_realestate_listing.py new file mode 100644 index 0000000..937f38e --- /dev/null +++ b/agent-office/app/notifiers/telegram_realestate_listing.py @@ -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} diff --git a/agent-office/tests/test_realestate_listing_notify.py b/agent-office/tests/test_realestate_listing_notify.py new file mode 100644 index 0000000..44ca3c2 --- /dev/null +++ b/agent-office/tests/test_realestate_listing_notify.py @@ -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