| """Trait 벡터 계산 + 유형 분류 + 매칭 엔진""" |
|
|
| import json |
| import math |
| import os |
| from scenarios import ALL_AXES, DATING_TYPES |
|
|
|
|
| def empty_vector() -> dict: |
| """15축 0 벡터 생성""" |
| return {axis: 0.0 for axis in ALL_AXES} |
|
|
|
|
| def apply_effects(vector: dict, effects: dict, multiplier: float = 1.0) -> dict: |
| """trait_effect를 벡터에 적용""" |
| for axis, delta in effects.items(): |
| if axis in vector: |
| vector[axis] = max(-5.0, min(5.0, vector[axis] + delta * multiplier)) |
| return vector |
|
|
|
|
| def classify_type(vector: dict) -> tuple[str, dict[str, float]]: |
| """유형 카드 분류 — 가중 점수 계산 후 Top-1 반환""" |
| scores = {} |
| for type_id, dtype in DATING_TYPES.items(): |
| score = 0.0 |
| for axis, weight in dtype["weights"].items(): |
| score += vector.get(axis, 0.0) * weight |
| scores[type_id] = round(score, 2) |
|
|
| best = max(scores, key=scores.get) |
| return best, scores |
|
|
|
|
| def vector_magnitude(v: dict) -> float: |
| return math.sqrt(sum(val ** 2 for val in v.values())) |
|
|
|
|
| def cosine_similarity(a: dict, b: dict) -> float: |
| """두 벡터의 코사인 유사도""" |
| axes = set(a.keys()) & set(b.keys()) |
| dot = sum(a[k] * b[k] for k in axes) |
| mag_a = math.sqrt(sum(a[k] ** 2 for k in axes)) |
| mag_b = math.sqrt(sum(b[k] ** 2 for k in axes)) |
| if mag_a == 0 or mag_b == 0: |
| return 0.0 |
| return dot / (mag_a * mag_b) |
|
|
|
|
| def merge_vectors(cumulative: dict, current: dict, weight_cum: float = 0.6) -> dict: |
| """누적 벡터 + 현재 런 벡터 병합""" |
| merged = {} |
| weight_cur = 1.0 - weight_cum |
| for axis in ALL_AXES: |
| merged[axis] = round( |
| cumulative.get(axis, 0.0) * weight_cum + current.get(axis, 0.0) * weight_cur, |
| 3, |
| ) |
| return merged |
|
|
|
|
| def vector_to_profile(vector: dict, type_id: str) -> dict: |
| """15축 벡터를 seed_profiles.json과 동일한 포맷으로 변환""" |
| from scenarios import BEHAVIOR_AXES, BIG5_AXES |
| dt = DATING_TYPES.get(type_id, {}) |
| return { |
| "archetype": dt.get("name", ""), |
| "traits": {axis: round(vector.get(axis, 0.0), 2) for axis in BEHAVIOR_AXES}, |
| "big_five": {axis: round(vector.get(axis, 0.0), 2) for axis in BIG5_AXES}, |
| } |
|
|
|
|
| def format_profile_display(profile: dict) -> str: |
| """seed_profiles 포맷 프로필을 CLI용으로 출력""" |
| lines = [] |
| lines.append(f" 아키타입: {profile.get('archetype', '?')}") |
| lines.append("") |
| lines.append(" ── 행동 성향 (10축) ──") |
| for axis, val in profile.get("traits", {}).items(): |
| bar_len = min(int(abs(val) * 4), 20) |
| if val >= 0: |
| bar = "█" * bar_len + "░" * (20 - bar_len) |
| else: |
| bar = "░" * (20 - bar_len) + "█" * bar_len |
| lines.append(f" {axis:>22s} {bar} {val:+.2f}") |
| lines.append("") |
| lines.append(" ── 성격 (Big Five) ──") |
| for axis, val in profile.get("big_five", {}).items(): |
| bar_len = min(int(abs(val) * 4), 20) |
| if val >= 0: |
| bar = "█" * bar_len + "░" * (20 - bar_len) |
| else: |
| bar = "░" * (20 - bar_len) + "█" * bar_len |
| lines.append(f" {axis:>22s} {bar} {val:+.2f}") |
| return "\n".join(lines) |
|
|
|
|
| def format_profile_md(profile: dict) -> str: |
| """seed_profiles 포맷 프로필을 마크다운으로 출력""" |
| md = f"**아키타입:** {profile.get('archetype', '?')}\n\n" |
| md += "#### 행동 성향 (10축)\n" |
| md += "| 축 | 값 |\n|---|---|\n" |
| for axis, val in profile.get("traits", {}).items(): |
| md += f"| {axis} | `{val:+.2f}` |\n" |
| md += "\n#### 성격 (Big Five)\n" |
| md += "| 축 | 값 |\n|---|---|\n" |
| for axis, val in profile.get("big_five", {}).items(): |
| md += f"| {axis} | `{val:+.2f}` |\n" |
| return md |
|
|
|
|
| def format_profile_json(profile: dict) -> str: |
| """seed_profiles 포맷 프로필을 JSON 문자열로""" |
| return json.dumps(profile, ensure_ascii=False, indent=2) |
|
|
|
|
| def format_type_card(type_id: str) -> str: |
| """유형 카드 출력""" |
| dt = DATING_TYPES[type_id] |
| return f""" |
| ╔══════════════════════════════════════╗ |
| ║ {dt['emoji']} {dt['title']} |
| ║ |
| ║ {dt['desc']} |
| ╚══════════════════════════════════════╝ |
| """ |
|
|
|
|
| |
|
|
| SEED_DATA_PATH = os.path.join(os.path.dirname(__file__), "data", "seed_profiles_1000.json") |
|
|
|
|
| def load_seed_profiles() -> list[dict]: |
| """seed_profiles.json 로드""" |
| with open(SEED_DATA_PATH, encoding="utf-8") as f: |
| return json.load(f) |
|
|
|
|
| def profile_to_vector(profile: dict) -> dict: |
| """시드 프로필의 traits + big_five를 15축 벡터로 변환""" |
| vector = {} |
| vector.update(profile.get("traits", {})) |
| vector.update(profile.get("big_five", {})) |
| return vector |
|
|
|
|
| def find_matches(user_vector: dict, user_type: str, top_n: int = 3) -> list[dict]: |
| """시드 프로필 80명 중 매칭 상위 N명 추천 |
| |
| match_score = 0.4 * behavior_score + 0.3 * big5_score + 0.2 * cluster_score + 0.1 * diversity |
| """ |
| profiles = load_seed_profiles() |
| results = [] |
|
|
| for profile in profiles: |
| candidate_vector = profile_to_vector(profile) |
|
|
| |
| behavior_score = cosine_similarity( |
| {k: user_vector.get(k, 0) for k in ALL_AXES[:10]}, |
| {k: candidate_vector.get(k, 0) for k in ALL_AXES[:10]}, |
| ) |
|
|
| |
| big5_score = cosine_similarity( |
| {k: user_vector.get(k, 0) for k in ALL_AXES[10:]}, |
| {k: candidate_vector.get(k, 0) for k in ALL_AXES[10:]}, |
| ) |
|
|
| |
| cand_type, _ = classify_type(candidate_vector) |
| complementary = { |
| "flame": "stable", "stable": "flame", |
| "emotional": "strategic", "strategic": "emotional", |
| "free": "stable", |
| } |
| if cand_type == complementary.get(user_type, ""): |
| cluster_score = 1.0 |
| elif cand_type == user_type: |
| cluster_score = 0.7 |
| else: |
| cluster_score = 0.3 |
|
|
| match_score = ( |
| 0.4 * behavior_score |
| + 0.3 * big5_score |
| + 0.2 * cluster_score |
| + 0.1 * 0.5 |
| ) |
|
|
| results.append({ |
| "profile": profile, |
| "type": cand_type, |
| "match_score": round(match_score * 100, 1), |
| "behavior_sim": round(behavior_score * 100, 1), |
| "big5_sim": round(big5_score * 100, 1), |
| }) |
|
|
| results.sort(key=lambda x: x["match_score"], reverse=True) |
| return results[:top_n] |
|
|
|
|
| AXIS_NAMES_KR = { |
| "cooperation": "협력성", "leadership": "주도성", "emotional_depth": "감정 깊이", |
| "pace": "관계 속도", "humor": "유머", "risk": "모험성", |
| "contact_frequency": "연락 빈도", "affection": "애정 표현", |
| "jealousy": "질투/독점", "planning": "계획성", |
| "openness": "개방성", "conscientiousness": "성실성", |
| "extraversion": "외향성", "agreeableness": "친화성", "neuroticism": "정서 불안정성", |
| } |
|
|
| COMPLEMENTARY_TYPES = { |
| "flame": "stable", "stable": "flame", |
| "emotional": "strategic", "strategic": "emotional", |
| "free": "stable", |
| } |
|
|
|
|
| def generate_match_reason(user_vector: dict, user_type: str, match: dict) -> str: |
| """매칭 이유를 deterministic하게 생성.""" |
| cand_vector = profile_to_vector(match["profile"]) |
|
|
| similarities = [] |
| complements = [] |
| for axis in ALL_AXES: |
| u = user_vector.get(axis, 0) |
| c = cand_vector.get(axis, 0) |
| diff = abs(u - c) |
| magnitude = abs(u) + abs(c) |
| if magnitude > 1.0: |
| if diff < 1.0: |
| similarities.append((axis, magnitude)) |
| elif diff > 1.5 and u * c < 0: |
| complements.append((axis, abs(u), abs(c))) |
|
|
| similarities.sort(key=lambda x: -x[1]) |
| parts = [] |
|
|
| if similarities: |
| names = [AXIS_NAMES_KR.get(s[0], s[0]) for s in similarities[:2]] |
| parts.append(f"{', '.join(names)}이(가) 비슷해요") |
|
|
| if complements: |
| name = AXIS_NAMES_KR.get(complements[0][0], complements[0][0]) |
| parts.append(f"{name}에서 서로 보완돼요") |
|
|
| cand_type = match.get("type", "") |
| if cand_type == COMPLEMENTARY_TYPES.get(user_type, ""): |
| parts.append("상호 보완적인 유형이에요") |
| elif cand_type == user_type: |
| parts.append("같은 유형이라 공감대가 높아요") |
|
|
| return " · ".join(parts) if parts else "전반적인 성향이 잘 맞아요" |
|
|
|
|
| def format_match_card(match: dict, rank: int) -> str: |
| """매칭 결과 카드 출력""" |
| p = match["profile"] |
| dt = DATING_TYPES.get(match["type"], {}) |
| return f"""\ |
| ┌──────────────────────────────────────┐ |
| │ #{rank} 💞 {p['name']} ({p['age']}세, {p['gender']}) |
| │ 유형: {dt.get('emoji', '')} {dt.get('name', '')} ({p.get('archetype', '')}) |
| │ |
| │ 매칭 적합도 {match['match_score']}% |
| │ 행동 유사도 {match['behavior_sim']}% |
| │ 성격 유사도 {match['big5_sim']}% |
| └──────────────────────────────────────┘""" |
|
|