mingming2323's picture
Rename to 러브로그(LoveLog) and clean up unused files
ff932fe
"""GPT API 엔진 — 스테이지 기반 이벤트 생성"""
import json
import time
from openai import OpenAI
from scene_art import SCENE_TAGS_STR
MAX_RETRIES = 2
RETRY_DELAY = 1.0 # seconds
def _chat_with_retry(client: OpenAI, *, model: str, messages: list,
temperature: float = 0.9, retries: int = MAX_RETRIES) -> dict:
"""GPT chat completion with retry on failure."""
last_err = None
for attempt in range(1 + retries):
try:
resp = client.chat.completions.create(
model=model, messages=messages,
response_format={"type": "json_object"},
temperature=temperature,
)
return json.loads(resp.choices[0].message.content)
except Exception as e:
last_err = e
if attempt < retries:
time.sleep(RETRY_DELAY)
raise last_err
SYSTEM_PROMPT = """\
너는 20-30대 커플의 연애 과정을 관찰하는 다큐멘터리 감독이다.
카메라는 그냥 거기 있고, 두 사람은 카메라를 의식하지 않는다.
유저는 이 상황 속 한 사람이 되어 자신의 성향을 자연스럽게 드러낸다.
■ 관찰 규칙:
- 감정을 설명하지 마라. 행동만 보여줘라.
❌ "설레는 마음을 감추며" / "묘한 긴장감이 흐른다"
✅ "괜히 메뉴판을 두 번 넘긴다" / "핸드폰을 만지작거리다가 올려다본다"
- 구체적인 디테일을 써라.
❌ "카페에서 대화를 나눈다"
✅ "을지로 골목 2층, 창가 자리. 아이스 아메리카노 얼음이 거의 녹았다."
- 어색한 순간, 침묵, 타이밍을 놓치는 순간을 자연스럽게 넣어라.
- 드라마틱한 이벤트(사고, 우연의 일치, 갑작스러운 고백)는 최소화.
■ 캐릭터:
- 상대방의 이름, 직업, 성격, 말투 정보가 주어진다.
- 상대 대사는 반드시 그 캐릭터의 speech_style에 맞게 작성하라.
- 상대를 이름으로 지칭한다.
- 상대의 행동/대사도 situation과 reaction에 포함시켜라.
■ 대사 규칙:
- 실제 20-30대가 대면이나 카톡에서 쓰는 말투 그대로.
❌ "의외로 너 이런 분위기도 잘 어울린다"
✅ "아 근데 여기 생각보다 괜찮다 진짜"
- 말이 끊기거나, 얼버무리거나, 웃음으로 때우는 것도 자연스러움.
- 큰따옴표 없이 문장만 작성.
■ 선택지:
- text는 유저가 직접 말하는 대사 + 짧은 행동 묘사 가능.
✅ "아 진짜? 하며 웃는다"
✅ "핸드폰을 꺼내 연락처를 건넨다"
✅ "... 괜찮아, 별거 아니야"
- 3개. 정답 없음.
- "이 상황에서 실제로 사람들이 하는 서로 다른 반응"이어야 한다.
- 적극/회피/관망 같은 고정 프레임 금지.
■ 달달함과 갈등:
- 달달함 = 사소한 순간. 같이 편의점에서 뭐 먹을지 고르기, 걷다가 손이 스치기.
- 갈등 = 현실적 마찰. 답장 늦기, 약속 까먹기, 사소한 말투 차이.
■ 난입 규칙:
- 난입 이벤트가 요청되면: 현재 상대와 관계가 애매해진 상황에서, 다른 캐릭터가 자연스럽게 등장한다.
- 난입 방식: 카톡이 온다, 우연히 마주친다, 같이 밥 먹자고 연락 온다 등.
- 유저가 마음이 흔들릴 수 있는 상황을 만들어라.
■ 연결된 서사:
- 이전 이벤트의 상황과 유저의 선택이 주어지면, 새로운 상황은 그 맥락 위에서 자연스럽게 이어져야 한다.
■ 볼드 규칙:
- 눈에 보이는 행동이나 구체적 사물만 **볼드**로 강조.
■ 1턴 vs 2턴:
- 1턴 = 이 상황에서 뭘 할 것인가
- 2턴 = 상대 반응을 본 뒤, 어떻게 이어갈 것인가
- 2턴의 상황 전개는 1턴 선택에 따라 완전히 달라져야 한다.
■ 모든 응답은 지정된 JSON 형식으로만 출력한다.
"""
def _char_context(character: dict) -> str:
"""캐릭터 정보를 프롬프트용 문자열로."""
return (
f"이름: {character['name']} ({character['age']}세, {character['job']})\n"
f"성격: {character['personality']}\n"
f"말투: {character['speech_style']}"
)
def _build_story_context(event_logs: list[dict]) -> str:
"""이전 이벤트 요약."""
if not event_logs:
return "없음 (첫 번째 이벤트)"
lines = []
for log in event_logs[-3:]:
name = log.get("scenario", "?")
situation = log.get("situation", "")
reaction = log.get("reaction", "")
t1 = log.get("turn1", {}).get("selected", {}).get("text", "")
t2 = log.get("turn2", {}).get("selected", {}).get("text", "")
lines.append(
f"[{name}] {situation[:80]}... → 유저: \"{t1}\" → {reaction[:60]}... → 유저: \"{t2}\""
)
return "\n".join(lines)
# ── 첫만남 장면 배치 생성 (1 GPT call) ──────────────────────
def generate_first_meetings(
client: OpenAI,
characters: list[dict],
model: str = "gpt-4o-mini",
) -> list[dict]:
"""3명 캐릭터의 첫만남 장면을 한번에 생성."""
char_descriptions = []
for i, c in enumerate(characters):
char_descriptions.append(
f"캐릭터 {i+1}:\n"
f" {_char_context(c)}\n"
f" 첫만남 유형: {c['meeting_type']}{c['meeting_desc']}"
)
chars_str = "\n\n".join(char_descriptions)
user_prompt = f"""\
아래 3명의 캐릭터와의 첫만남 장면을 각각 만들어줘.
각 장면은 그 캐릭터의 성격/말투에 맞는 짧은 상황 묘사(2~3문장)와 상대의 첫 대사를 포함해야 한다.
{chars_str}
아래 JSON 형식으로:
{{
"meetings": [
{{
"character_id": 0,
"situation": "다큐 카메라가 포착한 첫만남 장면. 2~3문장. 핵심 행동은 **볼드**.",
"dialogue": "상대방의 첫 대사 (캐릭터 말투에 맞게)"
}},
{{
"character_id": 1,
"situation": "...",
"dialogue": "..."
}},
{{
"character_id": 2,
"situation": "...",
"dialogue": "..."
}}
]
}}
"""
data = _chat_with_retry(
client, model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
return data.get("meetings", [])
# ── 스테이지 이벤트 생성 ─────────────────────────────────────
def generate_event(
client: OpenAI,
stage: dict,
character: dict,
warmth: float,
current_traits: dict,
event_logs: list[dict] = None,
variant: str = "normal",
model: str = "gpt-4o-mini",
) -> dict:
"""이벤트 1턴(상황 + 선택지) 생성."""
axes_str = ", ".join(stage["axes"])
story_context = _build_story_context(event_logs or [])
char_str = _char_context(character)
warmth_desc = "차가움" if warmth < 2 else ("미지근" if warmth < 5 else "따뜻함")
variant_hint = ""
if variant == "cold":
variant_hint = "관계가 멀어지고 있다. 연락이 뜸해지고, 대화가 어색해지는 방향으로."
elif variant == "warm":
variant_hint = "관계가 깊어지고 있다. 서로에 대한 확신이 생기는 방향으로."
traits_str = json.dumps(current_traits, ensure_ascii=False)
user_prompt = f"""\
═══ 상대 캐릭터 ═══
{char_str}
═══ 관계 상태 ═══
스테이지: {stage['name']}{stage['desc']}
관계 온도: {warmth:.1f} ({warmth_desc})
유저 현재 성향: {traits_str}
{variant_hint}
═══ 이전 흐름 ═══
{story_context}
═══ 측정 축 ═══
{axes_str}
위 맥락에 이어지는 현실적인 상황을 만들어줘.
상대({character['name']})의 대사와 행동을 캐릭터 성격/말투에 맞게 포함시켜.
아래 JSON 형식으로:
{{
"scene_tag": "장소 태그",
"situation": "다큐 카메라가 포착한 장면. 상대 이름/대사 포함. 3~5문장. 핵심 행동은 **볼드**.",
"choices": [
{{"id": "A", "text": "현실적 대사 또는 행동", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }},
{{"id": "B", "text": "다른 방향의 반응", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }},
{{"id": "C", "text": "또 다른 방향의 반응", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }}
]
}}
★ scene_tag: 장면이 벌어지는 장소. 다음 중 하나: {SCENE_TAGS_STR}
★ warmth_delta: 이 선택이 관계 온도에 미치는 영향. -2.0 ~ 2.0 범위.
가까워지는 선택은 양수, 멀어지는 선택은 음수.
★ trait_effect: 4~6개 축, -1.0 ~ 1.0
15축: cooperation, leadership, emotional_depth, pace, humor, risk, contact_frequency, affection, jealousy, planning, openness, conscientiousness, extraversion, agreeableness, neuroticism
"""
return _chat_with_retry(
client, model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
def generate_reaction(
client: OpenAI,
stage: dict,
character: dict,
situation: str,
user_choice: dict,
event_logs: list[dict] = None,
model: str = "gpt-5-chat-latest",
) -> dict:
"""상황 전개 + 2턴 선택지 생성."""
axes_str = ", ".join(stage["axes"])
story_context = _build_story_context(event_logs or [])
char_str = _char_context(character)
user_prompt = f"""\
═══ 상대 캐릭터 ═══
{char_str}
═══ 지금까지의 흐름 ═══
{story_context}
═══════════════════════
현재 상황: {situation}
유저가 한 것: {user_choice['text']}
측정 축: {axes_str}
유저가 "{user_choice['text']}"라고 했다/했다.
{character['name']}의 반응을 캐릭터 성격/말투에 맞게 보여줘.
아래 JSON 형식으로:
{{
"reaction": "{character['name']}의 반응. 감정 설명 없이 행동/표정/대사만. 2~3문장. 핵심 행동은 **볼드**.",
"choices": [
{{"id": "A", "text": "현실적 후속 반응", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }},
{{"id": "B", "text": "다른 방향", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }},
{{"id": "C", "text": "또 다른 방향", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }}
]
}}
★ warmth_delta: -2.0 ~ 2.0. 가까워지면 양수, 멀어지면 음수.
★ trait_effect: 4~6개 축, -1.0 ~ 1.0
15축: cooperation, leadership, emotional_depth, pace, humor, risk, contact_frequency, affection, jealousy, planning, openness, conscientiousness, extraversion, agreeableness, neuroticism
"""
return _chat_with_retry(
client, model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
# ── 난입 이벤트 생성 ─────────────────────────────────────────
def generate_interruption(
client: OpenAI,
current_char: dict,
interrupting_char: dict,
warmth: float,
event_logs: list[dict] = None,
model: str = "gpt-4o-mini",
) -> dict:
"""다른 캐릭터가 난입하는 이벤트 생성."""
story_context = _build_story_context(event_logs or [])
user_prompt = f"""\
═══ 현재 상황 ═══
현재 만나고 있는 사람: {current_char['name']} ({current_char['job']})
관계 온도: {warmth:.1f} (미지근하거나 차가움)
═══ 난입하는 사람 ═══
{_char_context(interrupting_char)}
첫만남 유형: {interrupting_char.get('meeting_type', '?')}
═══ 이전 흐름 ═══
{story_context}
═══════════════
{current_char['name']}과(와)의 관계가 애매해진 상황에서,
{interrupting_char['name']}이(가) 자연스럽게 등장하는 장면을 만들어줘.
(카톡이 온다, 우연히 마주친다, 같이 밥 먹자고 연락 등)
유저가 마음이 흔들릴 수 있는 상황이어야 한다.
아래 JSON 형식으로:
{{
"scene_tag": "장소 태그",
"situation": "난입 장면. {interrupting_char['name']}의 대사 포함. 3~4문장. **볼드** 사용.",
"choices": [
{{"id": "A", "text": "{interrupting_char['name']}에게 반응하는 선택", "target_char": {interrupting_char['id']}, "warmth_delta": 1.0, "trait_effect": {{...}} }},
{{"id": "B", "text": "{current_char['name']}에게 집중하는 선택", "target_char": {current_char['id']}, "warmth_delta": 0.5, "trait_effect": {{...}} }},
{{"id": "C", "text": "어느 쪽도 아닌 반응", "target_char": null, "warmth_delta": 0.0, "trait_effect": {{...}} }}
]
}}
★ scene_tag: 장면이 벌어지는 장소. 다음 중 하나: {SCENE_TAGS_STR}
★ target_char: 이 선택이 호감도를 올리는 대상의 id. null이면 아무도 아님.
★ warmth_delta: 해당 target_char의 호감도 변화. -2.0 ~ 2.0.
★ trait_effect: 4~6개 축, -1.0 ~ 1.0
"""
return _chat_with_retry(
client, model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
# ── 결말 생성 ─────────────────────────────────────────────────
def generate_ending(
client: OpenAI,
character: dict,
warmth: float,
ending_type: str,
event_logs: list[dict] = None,
model: str = "gpt-4o-mini",
) -> dict:
"""warmth 기반 결말 생성."""
story_context = _build_story_context(event_logs or [])
char_str = _char_context(character)
ending_hints = {
"mutual": "상대도 유저에게 마음이 있었다. 서로 고백하게 되는 장면. 두근거리는 확인의 순간.",
"accepted": "유저가 고백했고, 상대가 받아주는 장면. 조심스럽지만 기쁜 순간.",
"soft_reject": "유저가 고백했지만, 상대가 조심스럽게 거절하는 장면. 하지만 좋은 친구로 남기로 하는 담담한 순간.",
"rejected": "유저가 고백했지만, 상대가 거절하는 장면. 아쉽지만 각자의 길을 가는 순간.",
"friends": "유저가 모두와 친구로 남기로 했다. 연인은 아니지만 편한 관계로 남는 장면.",
"drifted": "서로 연락이 뜸해지고, 자연스럽게 멀어지는 장면. 아쉽지만 그게 현실인 순간.",
}
user_prompt = f"""\
═══ 상대 캐릭터 ═══
{char_str}
═══ 관계 온도 ═══
{warmth:.1f}
═══ 결말 방향 ═══
{ending_type}: {ending_hints.get(ending_type, '')}
═══ 이전 흐름 ═══
{story_context}
═══════════════
이 관계의 결말을 다큐 카메라가 포착한 마지막 장면으로 보여줘.
{character['name']}의 마지막 대사를 포함시켜.
아래 JSON 형식으로:
{{
"scene_tag": "장소 태그",
"ending_scene": "결말 장면. 4~6문장. 핵심 행동/사물 **볼드**.",
"ending_dialogue": "{character['name']}의 마지막 대사",
"ending_type": "{ending_type}"
}}
★ scene_tag: 결말이 벌어지는 장소. 다음 중 하나: {SCENE_TAGS_STR}
"""
return _chat_with_retry(
client, model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
# ── 성향 분석 생성 ───────────────────────────────────────────────
ANALYSIS_SYSTEM = """\
너는 연애 성향 분석 전문가이다.
유저가 가상 연애 시뮬레이션에서 한 선택들을 바탕으로 연애 스타일을 분석한다.
따뜻하고 공감하는 톤으로, 구체적인 선택을 근거로 들어 분석한다.
반말(~해요체) 사용. 짧고 임팩트 있게.
"""
def generate_analysis(
client: OpenAI,
trait_vector: dict,
type_id: str,
type_name: str,
event_logs: list[dict],
ending_type: str,
model: str = "gpt-4o-mini",
) -> dict:
"""플레이 결과 기반 성향 분석 생성."""
choice_lines = []
for log in event_logs[-4:]:
scenario = log.get("scenario", "?")
t1 = log.get("turn1", {}).get("selected", {}).get("text", "")
t2 = log.get("turn2", {}).get("selected", {}).get("text", "")
if t1:
choice_lines.append(f"[{scenario}] 1턴: {t1}")
if t2:
choice_lines.append(f"[{scenario}] 2턴: {t2}")
choices_str = "\n".join(choice_lines) if choice_lines else "없음"
ending_labels = {
"mutual": "서로 고백 → 사귐",
"accepted": "고백 수락 → 사귐",
"soft_reject": "거절당했지만 친구로",
"rejected": "고백 거절당함",
"friends": "모두와 친구로 남음",
"drifted": "자연스럽게 멀어짐",
}
traits_str = json.dumps(trait_vector, ensure_ascii=False)
user_prompt = f"""\
유저의 연애 시뮬레이션 결과를 분석해줘.
■ 연애 유형: {type_name} ({type_id})
■ 결말: {ending_labels.get(ending_type, ending_type)}
■ 15축 성향 벡터: {traits_str}
■ 핵심 선택들:
{choices_str}
아래 JSON으로:
{{
"summary": "2-3문장. 이 사람의 연애 스타일 핵심 요약. 구체적 선택을 언급해서.",
"strengths": ["강점 1 (한 줄)", "강점 2 (한 줄)"],
"watch_points": ["주의할 점 1 (한 줄)"],
"dating_tip": "이 유형에게 맞는 연애 팁 한 줄"
}}
"""
return _chat_with_retry(
client, model=model,
messages=[
{"role": "system", "content": ANALYSIS_SYSTEM},
{"role": "user", "content": user_prompt},
],
temperature=0.8,
)
# ── 성향 서술 생성 ─────────────────────────────────────────────────
def generate_personality_description(
client: OpenAI,
profile: dict,
type_info: dict,
event_logs: list[dict] = None,
model: str = "gpt-4o-mini",
) -> str:
"""유저의 성향 벡터를 바탕으로 자연어 서술을 생성."""
story_context = _build_story_context(event_logs or [])
traits_str = json.dumps(profile.get("traits", {}), ensure_ascii=False, indent=2)
big5_str = json.dumps(profile.get("big_five", {}), ensure_ascii=False, indent=2)
user_prompt = f"""\
아래는 유저가 연애 시뮬레이션에서 보여준 성향 데이터야.
이걸 바탕으로, 이 사람이 연애할 때 어떤 사람인지 2인칭(당신)으로 따뜻하게 서술해줘.
═══ 유형 ═══
{type_info.get('emoji', '')} {type_info.get('title', '')}
{type_info.get('desc', '')}
═══ 행동 성향 (10축) ═══
{traits_str}
═══ 성격 (Big Five) ═══
{big5_str}
═══ 플레이 중 선택 흐름 ═══
{story_context}
■ 작성 규칙:
- 3~5문장. 너무 길지 않게.
- 수치를 직접 언급하지 마. 자연스러운 문장으로 표현해.
- "당신은 ~한 사람이에요" 같은 따뜻한 톤.
- 플레이 중 보여준 선택 패턴을 자연스럽게 녹여서.
- JSON 아닌 순수 텍스트로만 응답해.
"""
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "너는 연애 성향 분석 전문가야. 따뜻하고 공감 어린 톤으로 서술한다."},
{"role": "user", "content": user_prompt},
],
temperature=0.85,
)
return resp.choices[0].message.content.strip()