Skip to content

Commit 4472c4c

Browse files
committed
Merge branch 'dev'
2 parents 95042ed + 47e32c4 commit 4472c4c

12 files changed

Lines changed: 633 additions & 61 deletions

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ services:
4545
WEB_HOST: ${WEB_HOST}
4646
WEB_PORT: ${WEB_PORT}
4747
# LLM Settings
48-
LLM_MODEL_NAME: "${LLM_MODEL_NAME:-gemini-2.5-flash-lite}"
48+
LLM_MODEL_NAME: "${LLM_MODEL_NAME:-gpt-4o-mini}"
4949

5050
networks:
5151
- platform-net

src/configs/llm.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class ChatMessage(BaseModel):
1818

1919

2020
class ChatCompletionRequest(BaseModel):
21-
model: str = "gpt-4"
21+
model: str = "gpt-4o-mini"
2222
messages: List[ChatMessage]
2323
temperature: float = 0.7
2424
max_tokens: Optional[int] = None

src/configs/llm_adapter.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import asyncio
23
from typing import Any, List, Optional
34

45
import httpx
@@ -34,6 +35,8 @@ class NarrativeChatModel(BaseChatModel):
3435
)
3536
client: httpx.AsyncClient
3637
temperature: float = 0.7
38+
llm_retry_attempts: int = 3
39+
llm_retry_base_delay: float = 0.8
3740

3841
@property
3942
def _llm_type(self) -> str:
@@ -87,11 +90,32 @@ async def _agenerate(
8790
tool_choice=kwargs.get("tool_choice"),
8891
)
8992

90-
response = await self.client.post(
91-
f"{self.base_url}/api/v1/chat/completions",
92-
json=request_body.model_dump(exclude_none=True),
93-
)
94-
response.raise_for_status()
93+
response: httpx.Response | None = None
94+
last_error: Exception | None = None
95+
for attempt in range(1, self.llm_retry_attempts + 1):
96+
try:
97+
response = await self.client.post(
98+
f"{self.base_url}/api/v1/chat/completions",
99+
json=request_body.model_dump(exclude_none=True),
100+
)
101+
if response.status_code >= 500:
102+
raise httpx.HTTPStatusError(
103+
f"llm gateway {response.status_code}",
104+
request=response.request,
105+
response=response,
106+
)
107+
response.raise_for_status()
108+
break
109+
except (httpx.RequestError, httpx.HTTPStatusError) as e:
110+
last_error = e
111+
if attempt >= self.llm_retry_attempts:
112+
raise
113+
await asyncio.sleep(self.llm_retry_base_delay * (2 ** (attempt - 1)))
114+
115+
if response is None:
116+
if last_error:
117+
raise last_error
118+
raise RuntimeError("LLM request failed without response")
95119

96120
chat_response = ChatCompletionResponse(**response.json())
97121

src/configs/llm_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class LLMManager:
1212
_instances = {}
1313

1414
@classmethod
15-
def get_instance(cls, provider="gemini", temperature=0.0):
15+
def get_instance(cls, provider="gateway", temperature=0.0):
1616
provider = provider.lower()
1717
instance_key = f"{provider}_{temperature}" # 고유 키
1818

src/domains/play/dtos/play_dtos.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ class PlaySceneRequest(BaseModel):
104104
session_id: str
105105
scenario_id: str
106106
locale_id: int
107+
sequence_type: Optional[str] = None
107108
entities: List[EntityUnit]
108109
relations: List[UpdateRelation]
109110
story: str

src/domains/play/utils/nodes.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from domains.info.world_service import WorldService
1313
from domains.play.dtos.play_dtos import (
1414
EntityType,
15+
PhaseType,
1516
PlaySessionState,
1617
SceneAnalysis,
1718
)
@@ -20,6 +21,29 @@
2021
from utils.proxy_request import proxy_request
2122

2223

24+
def _phase_from_sequence_type(raw: str | None) -> PhaseType | None:
25+
if not raw:
26+
return None
27+
norm = str(raw).strip().upper()
28+
if not norm:
29+
return None
30+
if any(k in norm for k in ["COMBAT", "BATTLE", "BOSS", "교전", "결전"]):
31+
return PhaseType.COMBAT
32+
if any(k in norm for k in ["DIALOG", "DIALOGUE", "대화"]):
33+
return PhaseType.DIALOGUE
34+
if any(k in norm for k in ["NEGO", "NEGOTIATION", "협상", "흥정"]):
35+
return PhaseType.NEGO
36+
if any(k in norm for k in ["REST", "휴식"]):
37+
return PhaseType.REST
38+
if any(k in norm for k in ["RECOVERY", "HEAL", "회복"]):
39+
return PhaseType.RECOVERY
40+
if any(k in norm for k in ["INFILTRATION", "STEALTH", "잠입"]):
41+
return PhaseType.EXPLORATION
42+
if any(k in norm for k in ["EXPLORATION", "EXPLORE", "탐색"]):
43+
return PhaseType.EXPLORATION
44+
return None
45+
46+
2347
async def get_player_state_from_proxy(player_id: str) -> FullPlayerState:
2448
"""
2549
플레이어 상태를 GDB로 관리하는 외부 마이크로서비스를 호출해서 정보를 조회합니다.
@@ -94,6 +118,22 @@ async def analyze_scene_node(state: PlaySessionState) -> Dict[str, Any]:
94118
"""
95119
LLM을 사용하여 스토리를 분석하고 페이즈 유형을 결정합니다.
96120
"""
121+
forced_phase = _phase_from_sequence_type(state.request.sequence_type)
122+
if forced_phase is not None:
123+
analysis = SceneAnalysis(
124+
phase_type=forced_phase,
125+
reason=f"sequence_type hint applied: {state.request.sequence_type}",
126+
confidence=1.0,
127+
)
128+
logs = state.logs[:]
129+
logs.append(f"분석된 플레이 유형: {analysis.phase_type}")
130+
logs.append(f"사유: {analysis.reason}")
131+
logs.append(f"분석 확신도: {analysis.confidence}")
132+
rule(f"분석된 플레이 유형: {analysis.phase_type}")
133+
rule(f"분석 근거: {analysis.reason}")
134+
rule(f"분석 확신도: {analysis.confidence}")
135+
return {"analysis": analysis, "logs": logs}
136+
97137
current_dir = os.path.dirname(os.path.abspath(__file__))
98138
prompt_path = os.path.join(current_dir, "..", "prompts", "instruction.md")
99139

src/domains/play/utils/phase_nodes/combat_node.py

Lines changed: 117 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict, List
1+
from typing import Any, Dict, List, Optional
22

33
from domains.gm.gm_service import GmService
44
from domains.info.enemy_service import EnemyService
@@ -9,9 +9,84 @@
99
PlaySessionState,
1010
RelationType,
1111
)
12+
from domains.play.dtos.player_dtos import ItemBase
1213
from utils.logger import rule
1314

1415

16+
def _try_parse_int(value: Optional[str]) -> Optional[int]:
17+
if value is None:
18+
return None
19+
raw = str(value).strip()
20+
if not raw or not raw.isdigit():
21+
return None
22+
return int(raw)
23+
24+
25+
def _is_combat_item_type(item_type: Optional[str]) -> bool:
26+
if not item_type:
27+
return False
28+
normalized = str(item_type).strip().lower()
29+
return normalized in {"무기", "방어구", "equipment", "weapon", "armor"}
30+
31+
32+
def _extract_effect_value(meta: Dict[str, Any]) -> int:
33+
if not isinstance(meta, dict):
34+
return 0
35+
for key in (
36+
"effect_value",
37+
"attack_bonus",
38+
"defense_bonus",
39+
"attack",
40+
"defense",
41+
"power",
42+
):
43+
value = meta.get(key)
44+
if value is None:
45+
continue
46+
try:
47+
return int(value)
48+
except (TypeError, ValueError):
49+
continue
50+
return 0
51+
52+
53+
async def _calculate_player_combat_item_effect(
54+
player_items: List[ItemBase], item_service: ItemService, logs: List[str]
55+
) -> int:
56+
numeric_item_ids: List[int] = []
57+
for item in player_items:
58+
parsed = _try_parse_int(item.item_id)
59+
if parsed is not None:
60+
numeric_item_ids.append(parsed)
61+
62+
effect_sum = 0
63+
resolved_numeric_ids: set[str] = set()
64+
if numeric_item_ids:
65+
items, _ = await item_service.get_items(
66+
item_ids=list(set(numeric_item_ids)), skip=0, limit=100
67+
)
68+
for db_item in items:
69+
if _is_combat_item_type(db_item.get("type")):
70+
effect_sum += int(db_item.get("effect_value") or 0)
71+
db_item_id = db_item.get("item_id")
72+
if db_item_id is not None:
73+
resolved_numeric_ids.add(str(db_item_id))
74+
75+
# scenario_item_id(문자열) 기반 인벤토리도 처리되도록 로컬 메타를 fallback으로 사용.
76+
for item in player_items:
77+
if item.item_id in resolved_numeric_ids:
78+
continue
79+
if not _is_combat_item_type(item.item_type):
80+
continue
81+
effect_sum += _extract_effect_value(item.meta)
82+
83+
if effect_sum > 0:
84+
logs.append(f"전투 보정치(아이템): {effect_sum}")
85+
rule(f"전투 보정치(아이템): {effect_sum}")
86+
87+
return effect_sum
88+
89+
1590
async def combat_node(state: PlaySessionState) -> Dict[str, Any]:
1691
"""
1792
전투 페이즈 로직을 처리합니다.
@@ -30,6 +105,7 @@ async def combat_node(state: PlaySessionState) -> Dict[str, Any]:
30105

31106
if not player_id or not player_state:
32107
logs.append("전투 페이즈: 플레이어 정보를 찾을 수 없습니다.")
108+
rule("전투 페이즈: 플레이어 정보를 찾을 수 없습니다.")
33109
return {
34110
"diffs": state.diffs,
35111
"relations": state.relations,
@@ -39,7 +115,7 @@ async def combat_node(state: PlaySessionState) -> Dict[str, Any]:
39115

40116
# 적 상세정보 조회
41117
combat_enemies_info = []
42-
enemy_state_ids = {
118+
hostile_enemy_state_ids = {
43119
(
44120
rel.effect_entity_id
45121
if rel.cause_entity_id == player_id
@@ -50,9 +126,22 @@ async def combat_node(state: PlaySessionState) -> Dict[str, Any]:
50126
and (rel.cause_entity_id == player_id or rel.effect_entity_id == player_id)
51127
}
52128

129+
# Fallback: relation 정보가 비어도 요청에 포함된 enemy 엔티티를 전투 대상으로 사용.
130+
enemy_state_ids = (
131+
hostile_enemy_state_ids
132+
if hostile_enemy_state_ids
133+
else {e.state_entity_id for e in enemies}
134+
)
135+
if not hostile_enemy_state_ids and enemies:
136+
no_enemies_log = (
137+
"적대 관계 정보가 없어 요청 enemy 목록 전체를 전투 대상으로 사용합니다."
138+
)
139+
logs.append(no_enemies_log)
140+
rule(no_enemies_log)
141+
53142
enemy_id_map = {
54143
e.state_entity_id: e.entity_id
55-
for e in entities_in_request
144+
for e in enemies
56145
if e.state_entity_id in enemy_state_ids and e.entity_id is not None
57146
}
58147

@@ -64,16 +153,26 @@ async def combat_node(state: PlaySessionState) -> Dict[str, Any]:
64153

65154
for state_id in enemy_state_ids:
66155
rdb_id = enemy_id_map.get(state_id)
67-
if rdb_id:
156+
base_difficulty = None
157+
if rdb_id is not None:
68158
enemy_data = enemy_details_map.get(rdb_id)
69-
if enemy_data and "base_difficulty" in enemy_data:
70-
combat_enemies_info.append(
71-
{
72-
"rdb_id": rdb_id,
73-
"state_id": state_id,
74-
"base_difficulty": enemy_data["base_difficulty"],
75-
}
76-
)
159+
if enemy_data and enemy_data.get("base_difficulty") is not None:
160+
base_difficulty = int(enemy_data["base_difficulty"])
161+
162+
if base_difficulty is None:
163+
# 상세 조회 실패 시에도 전투 처리를 중단하지 않기 위한 기본 난이도
164+
base_difficulty = 6
165+
no_enemy_detail_log = f"적 상세정보 누락(state_id={state_id}, rdb_id={rdb_id})으로 기본 난이도 6을 사용합니다."
166+
logs.append(no_enemy_detail_log)
167+
rule(no_enemy_detail_log)
168+
169+
combat_enemies_info.append(
170+
{
171+
"rdb_id": rdb_id,
172+
"state_id": state_id,
173+
"base_difficulty": base_difficulty,
174+
}
175+
)
77176

78177
# 플레이어 전투력 계산
79178
combat_logs: List[str] = []
@@ -82,13 +181,9 @@ async def combat_node(state: PlaySessionState) -> Dict[str, Any]:
82181
rule(dice_result_log)
83182
combat_logs.append(dice_result_log)
84183

85-
combat_items_effect = 0
86-
item_ids = [int(item.item_id) for item in player_state.player.items]
87-
if len(item_ids) > 0:
88-
items, _ = await item_service.get_items(item_ids=item_ids, skip=0, limit=100)
89-
combat_items_effect = sum(
90-
item["effect_value"] for item in items if item["type"] in ("무기", "방어구")
91-
)
184+
combat_items_effect = await _calculate_player_combat_item_effect(
185+
player_state.player.items, item_service, logs
186+
)
92187

93188
ability_score = 2 # Hardcoded
94189
player_combat_power = combat_items_effect + ability_score + dice.total
@@ -125,6 +220,7 @@ async def combat_node(state: PlaySessionState) -> Dict[str, Any]:
125220
)
126221
diffs.append(new_diff)
127222
logs.append(f"전투 승리! 적에게 {power_gap}의 데미지를 입혔습니다.")
223+
rule(f"전투 승리! 적에게 {power_gap}의 데미지를 입혔습니다.")
128224

129225
elif power_gap < 0:
130226
is_success = False
@@ -134,8 +230,10 @@ async def combat_node(state: PlaySessionState) -> Dict[str, Any]:
134230
)
135231
diffs.append(new_diff)
136232
logs.append(f"전투 패배... 플레이어가 {-power_gap}의 데미지를 입었습니다.")
233+
rule(f"전투 패배... 플레이어가 {-power_gap}의 데미지를 입었습니다.")
137234
else:
138235
logs.append("막상막하의 대결이었습니다!")
236+
rule("막상막하의 대결이었습니다!")
139237

140238
return {
141239
"diffs": diffs,

0 commit comments

Comments
 (0)