diff --git a/realestate-lab/app/matcher.py b/realestate-lab/app/matcher.py index e1cc6f6..3df8b2b 100644 --- a/realestate-lab/app/matcher.py +++ b/realestate-lab/app/matcher.py @@ -6,6 +6,44 @@ from .db import _conn, _profile_row_to_dict logger = logging.getLogger("realestate-lab") +TIER_WEIGHTS = {"S": 1.00, "A": 0.80, "B": 0.60, "C": 0.40, "D": 0.20} + + +def _region_score(profile: Dict[str, Any], ann: Dict[str, Any]) -> tuple[int, list[str]]: + """지역 점수 계산. 광역 10점 + 자치구 5티어 가중치 0~25점. + 자치구 기준 미설정 시 광역 매칭만으로 35점 풀 점수(기존 호환). + """ + 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 and 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 + + +def _eligibility_score(eligible_types: List[str]) -> int: + """자격 점수 0~25. 첫 자격 15점 + 추가 자격당 5점, 최대 +10.""" + if not eligible_types: + return 0 + return 15 + min((len(eligible_types) - 1) * 5, 10) + + # house_secd → 주택유형 이름 매핑 _HOUSE_TYPE_MAP = { "01": "APT", @@ -60,18 +98,18 @@ def _compute_score( ann: Dict[str, Any], models: List[Dict[str, Any]], ) -> Dict[str, Any]: - """매칭 점수(0-100)와 사유를 계산한다.""" + """매칭 점수(0-100)와 사유를 계산한다. + 배분: 지역 35 / 유형 10 / 면적 15 / 가격 15 / 자격 25. + """ score = 0 reasons: List[str] = [] - # 1. 지역 (30점) - preferred_regions = profile.get("preferred_regions") or [] - region_name = ann.get("region_name") or "" - if region_name and any(r in region_name for r in preferred_regions): - score += 30 - reasons.append(f"선호 지역 일치: {region_name}") + # 1. 지역 (35점) — 광역 + 자치구 5티어 + region_score, region_reasons = _region_score(profile, ann) + score += region_score + reasons.extend(region_reasons) - # 2. 주택유형 (10점) + # 2. 주택유형 (10점) — binary preferred_types = profile.get("preferred_types") or [] house_secd = ann.get("house_secd") or "" type_name = _HOUSE_TYPE_MAP.get(house_secd, house_secd) @@ -79,7 +117,7 @@ def _compute_score( score += 10 reasons.append(f"선호 유형 일치: {type_name}") - # 3. 면적 (15점) + # 3. 면적 (15점) — binary, 범위 안 모델 1개라도 있으면 통과 min_area = profile.get("min_area") max_area = profile.get("max_area") if min_area is not None and max_area is not None and models: @@ -90,7 +128,7 @@ def _compute_score( reasons.append(f"희망 면적 범위 내 모델 존재 ({supply_area}㎡)") break - # 4. 가격 (15점) + # 4. 가격 (15점) — binary, 예산 이하 모델 1개라도 있으면 통과 max_price = profile.get("max_price") if max_price is not None and models: for m in models: @@ -100,11 +138,11 @@ def _compute_score( reasons.append(f"예산 범위 내 모델 존재 (최고가 {top_amount:,}만원)") break - # 5. 자격 (30점) + # 5. 자격 (25점) — 첫 자격 15 + 추가당 5 eligible_types = _check_eligible_types(profile, ann) - eligibility_score = min(len(eligible_types) * 10, 30) - if eligibility_score > 0: - score += eligibility_score + elig_score = _eligibility_score(eligible_types) + if elig_score > 0: + score += elig_score reasons.append(f"자격 유형 {len(eligible_types)}개: {', '.join(eligible_types)}") return { diff --git a/realestate-lab/tests/test_matcher.py b/realestate-lab/tests/test_matcher.py new file mode 100644 index 0000000..c757c64 --- /dev/null +++ b/realestate-lab/tests/test_matcher.py @@ -0,0 +1,83 @@ +def test_region_score_no_districts_full_when_region_match(): + """자치구 미설정: 광역 일치 시 35점.""" + from app.matcher import _region_score + profile = {"preferred_regions": ["서울"], "preferred_districts": {}} + ann = {"region_name": "서울특별시", "district": None} + score, _ = _region_score(profile, ann) + assert score == 35 + + +def test_region_score_no_districts_zero_when_region_mismatch(): + from app.matcher import _region_score + profile = {"preferred_regions": ["서울"], "preferred_districts": {}} + ann = {"region_name": "부산광역시", "district": None} + score, _ = _region_score(profile, ann) + assert score == 0 + + +def test_region_score_s_tier_district(): + """광역 매칭 + S티어 자치구: 10 + 25 = 35.""" + from app.matcher import _region_score + profile = { + "preferred_regions": ["서울"], + "preferred_districts": {"S": ["강남구"], "A": [], "B": [], "C": [], "D": []}, + } + ann = {"region_name": "서울특별시", "district": "강남구"} + score, _ = _region_score(profile, ann) + assert score == 35 + + +def test_region_score_a_tier_district(): + """광역 매칭 + A티어 자치구: 10 + 20 = 30.""" + from app.matcher import _region_score + profile = { + "preferred_regions": ["서울"], + "preferred_districts": {"S": [], "A": ["송파구"], "B": [], "C": [], "D": []}, + } + ann = {"region_name": "서울특별시", "district": "송파구"} + score, _ = _region_score(profile, ann) + assert score == 30 + + +def test_region_score_d_tier_district(): + """광역 매칭 + D티어 자치구: 10 + 5 = 15.""" + from app.matcher import _region_score + profile = { + "preferred_regions": ["서울"], + "preferred_districts": {"S": [], "A": [], "B": [], "C": [], "D": ["도봉구"]}, + } + ann = {"region_name": "서울특별시", "district": "도봉구"} + score, _ = _region_score(profile, ann) + assert score == 15 + + +def test_region_score_district_set_but_not_listed(): + """광역 매칭 + 자치구 5티어 어디에도 없음: 10점만.""" + from app.matcher import _region_score + profile = { + "preferred_regions": ["서울"], + "preferred_districts": {"S": ["강남구"], "A": [], "B": [], "C": [], "D": []}, + } + ann = {"region_name": "서울특별시", "district": "강서구"} + score, _ = _region_score(profile, ann) + assert score == 10 + + +def test_eligibility_score_zero_when_empty(): + from app.matcher import _eligibility_score + assert _eligibility_score([]) == 0 + + +def test_eligibility_score_one_type_returns_15(): + from app.matcher import _eligibility_score + assert _eligibility_score(["일반1순위"]) == 15 + + +def test_eligibility_score_two_types_returns_20(): + from app.matcher import _eligibility_score + assert _eligibility_score(["일반1순위", "특별-신혼부부"]) == 20 + + +def test_eligibility_score_caps_at_25(): + from app.matcher import _eligibility_score + assert _eligibility_score(["a", "b", "c", "d", "e"]) == 25