rouge-like-matcing-LOVElog / trait_engine.py
mingming2323's picture
Rename to 러브로그(LoveLog) and clean up unused files
ff932fe
"""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']}%
└──────────────────────────────────────┘"""