feat(realestate): 5축 점수 breakdown + 대시보드 pass_count
- matcher: _compute_score()에 score_breakdown {region/type/area/price/eligibility} 반환
- matcher: run_matching() DB INSERT에 score_breakdown JSON 저장
- db: match_results에 score_breakdown 컬럼 마이그레이션
- db: _enrich_items / get_matches에서 score_breakdown 파싱 포함
- db: get_matches에 a.district 컬럼 추가
- db: get_dashboard()에 pass_count (min_match_score 임계값 통과 건수) 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -159,6 +159,12 @@ def init_db():
|
|||||||
except Exception:
|
except Exception:
|
||||||
conn.execute("ALTER TABLE match_results ADD COLUMN notified_at TEXT")
|
conn.execute("ALTER TABLE match_results ADD COLUMN notified_at TEXT")
|
||||||
|
|
||||||
|
# ── 마이그레이션: score_breakdown 컬럼 추가 ──
|
||||||
|
try:
|
||||||
|
conn.execute("SELECT score_breakdown FROM match_results LIMIT 1")
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ALTER TABLE match_results ADD COLUMN score_breakdown TEXT")
|
||||||
|
|
||||||
# ── collect_log ──────────────────────────────────────────────────
|
# ── collect_log ──────────────────────────────────────────────────
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS collect_log (
|
CREATE TABLE IF NOT EXISTS collect_log (
|
||||||
@@ -281,13 +287,14 @@ def _enrich_items(conn, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|||||||
# 매칭 점수
|
# 매칭 점수
|
||||||
if ann_id:
|
if ann_id:
|
||||||
match_row = conn.execute(
|
match_row = conn.execute(
|
||||||
"SELECT match_score, match_reasons, eligible_types FROM match_results WHERE announcement_id = ?",
|
"SELECT match_score, match_reasons, eligible_types, score_breakdown FROM match_results WHERE announcement_id = ?",
|
||||||
(ann_id,),
|
(ann_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if match_row:
|
if match_row:
|
||||||
item["match_score"] = match_row["match_score"]
|
item["match_score"] = match_row["match_score"]
|
||||||
item["match_reasons"] = json.loads(match_row["match_reasons"]) if match_row["match_reasons"] else []
|
item["match_reasons"] = json.loads(match_row["match_reasons"]) if match_row["match_reasons"] else []
|
||||||
item["eligible_types"] = json.loads(match_row["eligible_types"]) if match_row["eligible_types"] else []
|
item["eligible_types"] = json.loads(match_row["eligible_types"]) if match_row["eligible_types"] else []
|
||||||
|
item["score_breakdown"] = json.loads(match_row["score_breakdown"]) if match_row["score_breakdown"] else None
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@@ -681,7 +688,7 @@ def get_matches(page: int = 1, size: int = 20) -> Dict[str, Any]:
|
|||||||
|
|
||||||
total = conn.execute("SELECT COUNT(*) FROM match_results").fetchone()[0]
|
total = conn.execute("SELECT COUNT(*) FROM match_results").fetchone()[0]
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT m.*, a.house_nm, a.region_name, a.address, a.status as ann_status,
|
SELECT m.*, a.house_nm, a.region_name, a.address, a.district, a.status as ann_status,
|
||||||
a.receipt_start, a.receipt_end, a.winner_date, a.pblanc_url,
|
a.receipt_start, a.receipt_end, a.winner_date, a.pblanc_url,
|
||||||
a.house_secd, a.is_speculative_area
|
a.house_secd, a.is_speculative_area
|
||||||
FROM match_results m
|
FROM match_results m
|
||||||
@@ -695,6 +702,7 @@ def get_matches(page: int = 1, size: int = 20) -> Dict[str, Any]:
|
|||||||
d = {c: r[c] for c in r.keys()}
|
d = {c: r[c] for c in r.keys()}
|
||||||
d["match_reasons"] = json.loads(d["match_reasons"]) if d["match_reasons"] else []
|
d["match_reasons"] = json.loads(d["match_reasons"]) if d["match_reasons"] else []
|
||||||
d["eligible_types"] = json.loads(d["eligible_types"]) if d["eligible_types"] else []
|
d["eligible_types"] = json.loads(d["eligible_types"]) if d["eligible_types"] else []
|
||||||
|
d["score_breakdown"] = json.loads(d["score_breakdown"]) if d.get("score_breakdown") else None
|
||||||
items.append(d)
|
items.append(d)
|
||||||
return {
|
return {
|
||||||
"items": items,
|
"items": items,
|
||||||
@@ -776,6 +784,13 @@ def get_dashboard() -> Dict[str, Any]:
|
|||||||
bookmarked_count = conn.execute(
|
bookmarked_count = conn.execute(
|
||||||
"SELECT COUNT(*) FROM announcements WHERE is_bookmarked = 1"
|
"SELECT COUNT(*) FROM announcements WHERE is_bookmarked = 1"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
profile_row = conn.execute(
|
||||||
|
"SELECT min_match_score FROM user_profile WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
min_score = profile_row["min_match_score"] if profile_row else 70
|
||||||
|
pass_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM match_results WHERE match_score >= ?", (min_score,)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
# 다가오는 일정을 개별 이벤트로 분해
|
# 다가오는 일정을 개별 이벤트로 분해
|
||||||
upcoming_rows = conn.execute("""
|
upcoming_rows = conn.execute("""
|
||||||
@@ -820,6 +835,7 @@ def get_dashboard() -> Dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"active_count": active,
|
"active_count": active,
|
||||||
"new_match_count": new_matches,
|
"new_match_count": new_matches,
|
||||||
|
"pass_count": pass_count,
|
||||||
"bookmarked_count": bookmarked_count,
|
"bookmarked_count": bookmarked_count,
|
||||||
"upcoming_schedules": schedules,
|
"upcoming_schedules": schedules,
|
||||||
"bookmarked": bookmarked_items,
|
"bookmarked": bookmarked_items,
|
||||||
|
|||||||
@@ -113,27 +113,33 @@ def _compute_score(
|
|||||||
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)
|
||||||
|
type_score = 0
|
||||||
if type_name and type_name in preferred_types:
|
if type_name and type_name in preferred_types:
|
||||||
|
type_score = 10
|
||||||
score += 10
|
score += 10
|
||||||
reasons.append(f"선호 유형 일치: {type_name}")
|
reasons.append(f"선호 유형 일치: {type_name}")
|
||||||
|
|
||||||
# 3. 면적 (15점) — binary, 범위 안 모델 1개라도 있으면 통과
|
# 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")
|
||||||
|
area_score = 0
|
||||||
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:
|
||||||
for m in models:
|
for m in models:
|
||||||
supply_area = m.get("supply_area")
|
supply_area = m.get("supply_area")
|
||||||
if supply_area is not None and min_area <= supply_area <= max_area:
|
if supply_area is not None and min_area <= supply_area <= max_area:
|
||||||
|
area_score = 15
|
||||||
score += 15
|
score += 15
|
||||||
reasons.append(f"희망 면적 범위 내 모델 존재 ({supply_area}㎡)")
|
reasons.append(f"희망 면적 범위 내 모델 존재 ({supply_area}㎡)")
|
||||||
break
|
break
|
||||||
|
|
||||||
# 4. 가격 (15점) — binary, 예산 이하 모델 1개라도 있으면 통과
|
# 4. 가격 (15점) — binary, 예산 이하 모델 1개라도 있으면 통과
|
||||||
max_price = profile.get("max_price")
|
max_price = profile.get("max_price")
|
||||||
|
price_score = 0
|
||||||
if max_price is not None and models:
|
if max_price is not None and models:
|
||||||
for m in models:
|
for m in models:
|
||||||
top_amount = m.get("top_amount")
|
top_amount = m.get("top_amount")
|
||||||
if top_amount is not None and top_amount <= max_price:
|
if top_amount is not None and top_amount <= max_price:
|
||||||
|
price_score = 15
|
||||||
score += 15
|
score += 15
|
||||||
reasons.append(f"예산 범위 내 모델 존재 (최고가 {top_amount:,}만원)")
|
reasons.append(f"예산 범위 내 모델 존재 (최고가 {top_amount:,}만원)")
|
||||||
break
|
break
|
||||||
@@ -149,6 +155,13 @@ def _compute_score(
|
|||||||
"match_score": score,
|
"match_score": score,
|
||||||
"match_reasons": reasons,
|
"match_reasons": reasons,
|
||||||
"eligible_types": eligible_types,
|
"eligible_types": eligible_types,
|
||||||
|
"score_breakdown": {
|
||||||
|
"region": region_score,
|
||||||
|
"type": type_score,
|
||||||
|
"area": area_score,
|
||||||
|
"price": price_score,
|
||||||
|
"eligibility": elig_score,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -178,18 +191,20 @@ def run_matching():
|
|||||||
result = _compute_score(profile, ann, model_list)
|
result = _compute_score(profile, ann, model_list)
|
||||||
if result["match_score"] > 0:
|
if result["match_score"] > 0:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
|
INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, score_breakdown, is_new)
|
||||||
VALUES (?, ?, ?, ?, ?, 1)
|
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||||
ON CONFLICT(announcement_id, model_id) DO UPDATE SET
|
ON CONFLICT(announcement_id, model_id) DO UPDATE SET
|
||||||
match_score=excluded.match_score,
|
match_score=excluded.match_score,
|
||||||
match_reasons=excluded.match_reasons,
|
match_reasons=excluded.match_reasons,
|
||||||
eligible_types=excluded.eligible_types
|
eligible_types=excluded.eligible_types,
|
||||||
|
score_breakdown=excluded.score_breakdown
|
||||||
""", (
|
""", (
|
||||||
ann["id"],
|
ann["id"],
|
||||||
None,
|
None,
|
||||||
result["match_score"],
|
result["match_score"],
|
||||||
json.dumps(result["match_reasons"]),
|
json.dumps(result["match_reasons"]),
|
||||||
json.dumps(result["eligible_types"]),
|
json.dumps(result["eligible_types"]),
|
||||||
|
json.dumps(result["score_breakdown"]),
|
||||||
))
|
))
|
||||||
|
|
||||||
# Clean up stale match results for completed announcements
|
# Clean up stale match results for completed announcements
|
||||||
|
|||||||
Reference in New Issue
Block a user