feat(realestate-matcher): 5-tier district weighting + eligibility curve
지역 점수를 35점(광역 10 + 자치구 S/A/B/C/D 티어 0~25)으로 재배분하고, 자격 점수를 25점(첫 자격 15 + 추가당 5, 최대 +10) 곡선으로 변경. 총점 구성: 지역 35 + 유형 10 + 면적 15 + 가격 15 + 자격 25 = 100. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,44 @@ from .db import _conn, _profile_row_to_dict
|
|||||||
|
|
||||||
logger = logging.getLogger("realestate-lab")
|
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_secd → 주택유형 이름 매핑
|
||||||
_HOUSE_TYPE_MAP = {
|
_HOUSE_TYPE_MAP = {
|
||||||
"01": "APT",
|
"01": "APT",
|
||||||
@@ -60,18 +98,18 @@ def _compute_score(
|
|||||||
ann: Dict[str, Any],
|
ann: Dict[str, Any],
|
||||||
models: List[Dict[str, Any]],
|
models: List[Dict[str, Any]],
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""매칭 점수(0-100)와 사유를 계산한다."""
|
"""매칭 점수(0-100)와 사유를 계산한다.
|
||||||
|
배분: 지역 35 / 유형 10 / 면적 15 / 가격 15 / 자격 25.
|
||||||
|
"""
|
||||||
score = 0
|
score = 0
|
||||||
reasons: List[str] = []
|
reasons: List[str] = []
|
||||||
|
|
||||||
# 1. 지역 (30점)
|
# 1. 지역 (35점) — 광역 + 자치구 5티어
|
||||||
preferred_regions = profile.get("preferred_regions") or []
|
region_score, region_reasons = _region_score(profile, ann)
|
||||||
region_name = ann.get("region_name") or ""
|
score += region_score
|
||||||
if region_name and any(r in region_name for r in preferred_regions):
|
reasons.extend(region_reasons)
|
||||||
score += 30
|
|
||||||
reasons.append(f"선호 지역 일치: {region_name}")
|
|
||||||
|
|
||||||
# 2. 주택유형 (10점)
|
# 2. 주택유형 (10점) — binary
|
||||||
preferred_types = profile.get("preferred_types") or []
|
preferred_types = profile.get("preferred_types") or []
|
||||||
house_secd = ann.get("house_secd") or ""
|
house_secd = ann.get("house_secd") or ""
|
||||||
type_name = _HOUSE_TYPE_MAP.get(house_secd, house_secd)
|
type_name = _HOUSE_TYPE_MAP.get(house_secd, house_secd)
|
||||||
@@ -79,7 +117,7 @@ def _compute_score(
|
|||||||
score += 10
|
score += 10
|
||||||
reasons.append(f"선호 유형 일치: {type_name}")
|
reasons.append(f"선호 유형 일치: {type_name}")
|
||||||
|
|
||||||
# 3. 면적 (15점)
|
# 3. 면적 (15점) — binary, 범위 안 모델 1개라도 있으면 통과
|
||||||
min_area = profile.get("min_area")
|
min_area = profile.get("min_area")
|
||||||
max_area = profile.get("max_area")
|
max_area = profile.get("max_area")
|
||||||
if min_area is not None and max_area is not None and models:
|
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}㎡)")
|
reasons.append(f"희망 면적 범위 내 모델 존재 ({supply_area}㎡)")
|
||||||
break
|
break
|
||||||
|
|
||||||
# 4. 가격 (15점)
|
# 4. 가격 (15점) — binary, 예산 이하 모델 1개라도 있으면 통과
|
||||||
max_price = profile.get("max_price")
|
max_price = profile.get("max_price")
|
||||||
if max_price is not None and models:
|
if max_price is not None and models:
|
||||||
for m in models:
|
for m in models:
|
||||||
@@ -100,11 +138,11 @@ def _compute_score(
|
|||||||
reasons.append(f"예산 범위 내 모델 존재 (최고가 {top_amount:,}만원)")
|
reasons.append(f"예산 범위 내 모델 존재 (최고가 {top_amount:,}만원)")
|
||||||
break
|
break
|
||||||
|
|
||||||
# 5. 자격 (30점)
|
# 5. 자격 (25점) — 첫 자격 15 + 추가당 5
|
||||||
eligible_types = _check_eligible_types(profile, ann)
|
eligible_types = _check_eligible_types(profile, ann)
|
||||||
eligibility_score = min(len(eligible_types) * 10, 30)
|
elig_score = _eligibility_score(eligible_types)
|
||||||
if eligibility_score > 0:
|
if elig_score > 0:
|
||||||
score += eligibility_score
|
score += elig_score
|
||||||
reasons.append(f"자격 유형 {len(eligible_types)}개: {', '.join(eligible_types)}")
|
reasons.append(f"자격 유형 {len(eligible_types)}개: {', '.join(eligible_types)}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
83
realestate-lab/tests/test_matcher.py
Normal file
83
realestate-lab/tests/test_matcher.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user