"""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]}, ) # Big Five 유사도 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']}% └──────────────────────────────────────┘"""