| """GPT API 엔진 — 스테이지 기반 이벤트 생성""" |
|
|
| import json |
| import time |
| from openai import OpenAI |
| from scene_art import SCENE_TAGS_STR |
|
|
| MAX_RETRIES = 2 |
| RETRY_DELAY = 1.0 |
|
|
|
|
| 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) |
|
|
|
|
| |
|
|
| 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() |
|
|