ai 기반 문지기 npc의 조언 및 전투 스킬 생성 기능 구현
This commit is contained in:
@@ -1,12 +1,9 @@
|
||||
from langchain_ibm import WatsonxEmbeddings
|
||||
from backend.config.settings import settings
|
||||
from langchain_ollama import OllamaEmbeddings
|
||||
|
||||
watson_embedding = WatsonxEmbeddings(
|
||||
model_id="ibm/granite-embedding-278m-multilingual",
|
||||
url=f"{settings.watsonx_url}",
|
||||
api_key=f"{settings.watsonx_api_key}",
|
||||
project_id=f"{settings.watsonx_project_id}",
|
||||
)
|
||||
|
||||
ollama_embedding = OllamaEmbeddings(model="nomic-embed-text-v2-moe")
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
from langchain_ollama import ChatOllama
|
||||
|
||||
# 로컬 LLM
|
||||
qwen_llm = ChatOllama(model="qwen3.5:4b", temperature=0)
|
||||
exaone_llm = ChatOllama(model="exaone3.5:2.4b", temperature=0)
|
||||
gemma_llm = ChatOllama(model="gemma4:e2b")
|
||||
+12
-20
@@ -1,29 +1,21 @@
|
||||
from fastapi import FastAPI
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from contextlib import asynccontextmanager
|
||||
from backend.routers.npc_router import router as npc_router
|
||||
from backend.routers.skill_router import router as skill_router
|
||||
from backend.repository.db_init import Base, engine
|
||||
from backend.routers.game_router import router as game_router
|
||||
|
||||
from backend.repository.db_init import Base, SessionLocal, engine
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
print("서버 시작")
|
||||
|
||||
# @asynccontextmanager
|
||||
# async def lifespan(app: FastAPI):
|
||||
# print("서버 시작")
|
||||
#
|
||||
# # Base 에 등록된 모든 모델에 테이블 자동 생성
|
||||
# Base.metadata.create_all(bind=engine)
|
||||
# print("[DB] 테이블 생성 완료 (또는 이미 존재)")
|
||||
#
|
||||
# yield
|
||||
# print("서버 종료")
|
||||
# Base 에 등록된 모든 모델에 테이블 자동 생성
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("[DB] 테이블 생성 완료 (또는 이미 존재)")
|
||||
|
||||
# app = FastAPI()
|
||||
# app = FastAPI(title="상담 LLM", version="1.0", lifespan=lifespan)
|
||||
app = FastAPI(title="AI npc", version="1.0", description="던전 입구를 지키는 문지기 npc",)
|
||||
yield
|
||||
print("서버 종료")
|
||||
|
||||
# static 폴더 지정
|
||||
# app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
||||
app = FastAPI(title="로그라이크 AI 백엔드", version="1.0", lifespan=lifespan)
|
||||
|
||||
# 라우터 등록
|
||||
app.include_router(npc_router)
|
||||
app.include_router(skill_router)
|
||||
app.include_router(game_router)
|
||||
@@ -4,8 +4,8 @@ from pathlib import Path
|
||||
|
||||
Path("db").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# echo sql 구문 출력시키는
|
||||
engine = create_engine('sqlite:///db/ai.db', echo=True)
|
||||
engine = create_engine('sqlite:///db/roguelike.db', echo=True)
|
||||
|
||||
|
||||
# 세션 팩토리 생성
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from backend.repository.db_init import Base
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import String
|
||||
from datetime import datetime
|
||||
|
||||
# 선조 사망 기록 모델 (로그라이크)
|
||||
class AncestorDeath(Base):
|
||||
__tablename__ = 'ancestor_deaths'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
generation: Mapped[int] = mapped_column(nullable=False)
|
||||
cause_of_death: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
floor: Mapped[int] = mapped_column(nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(default=datetime.now, nullable=False)
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.repository.db_init import get_db
|
||||
from backend.schemas.game_schema import (
|
||||
AncestorDeathCreate,
|
||||
AncestorDeathResponse,
|
||||
DialogueResponse,
|
||||
SkillCreateRequest,
|
||||
SkillCreateResponse
|
||||
)
|
||||
from backend.services import game_service
|
||||
|
||||
router = APIRouter(prefix="/api/game", tags=["Game"])
|
||||
|
||||
@router.post("/death", response_model=AncestorDeathResponse)
|
||||
def record_death(req: AncestorDeathCreate, db: Session = Depends(get_db)):
|
||||
return game_service.create_death_history(db=db, death_data=req)
|
||||
|
||||
@router.get("/npc-dialogue", response_model=DialogueResponse)
|
||||
def get_npc_dialogue(current_generation: int = Query(..., description="현재 플레이어 세대"), db: Session = Depends(get_db)):
|
||||
dialogue_text = game_service.generate_npc_dialogue(db=db, current_generation=current_generation)
|
||||
return DialogueResponse(dialogue=dialogue_text)
|
||||
|
||||
@router.post("/generate-skill", response_model=SkillCreateResponse)
|
||||
def create_skill(req: SkillCreateRequest):
|
||||
return game_service.generate_ai_skill(prompt=req.prompt, player_level=req.player_level)
|
||||
@@ -1,13 +0,0 @@
|
||||
from fastapi import APIRouter, Request, UploadFile
|
||||
from backend.services.llm_service import question_and_answer
|
||||
from backend.schemas.basic_schema import QuestionRequest
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
router = APIRouter(prefix="/npc")
|
||||
|
||||
# http://127.0.0.1:8000/npc/question
|
||||
@router.post("/question")
|
||||
async def question(req: QuestionRequest):
|
||||
answer = question_and_answer(req.question)
|
||||
|
||||
return {"message" : answer}
|
||||
@@ -1,13 +0,0 @@
|
||||
from fastapi import APIRouter, Request, UploadFile
|
||||
from backend.services.llm_service import question_and_answer
|
||||
from backend.schemas.basic_schema import QuestionRequest
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
router = APIRouter(prefix="/npc")
|
||||
|
||||
# http://127.0.0.1:8000/npc/question
|
||||
@router.post("/question")
|
||||
async def question(req: QuestionRequest):
|
||||
answer = question_and_answer(req.question)
|
||||
|
||||
return {"message" : answer}
|
||||
@@ -1,4 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class QuestionRequest(BaseModel):
|
||||
question : str
|
||||
@@ -0,0 +1,39 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
class AncestorDeathCreate(BaseModel):
|
||||
name: str = Field(..., description="선조의 이름")
|
||||
generation: int = Field(..., description="선조의 세대 수")
|
||||
cause_of_death: str = Field(..., description="사망 원인 (예: 가시 함정, 화염 슬라임 등)")
|
||||
floor: int = Field(..., description="사망한 던전 층수")
|
||||
|
||||
class AncestorDeathResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
generation: int
|
||||
cause_of_death: str
|
||||
floor: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class DialogueRequest(BaseModel):
|
||||
current_generation: int = Field(..., description="현재 플레이어의 세대")
|
||||
|
||||
class DialogueResponse(BaseModel):
|
||||
dialogue: str = Field(..., description="늙은 문지기 NPC의 맞춤형 조언 대사")
|
||||
|
||||
class SkillCreateRequest(BaseModel):
|
||||
prompt: str = Field(..., description="스킬 생성을 위한 플레이어 텍스트 입력")
|
||||
player_level: int = Field(default=1, description="플레이어의 레벨 (스탯 밸런싱 기준)")
|
||||
|
||||
class SkillCreateResponse(BaseModel):
|
||||
skill_name: str = Field(..., description="생성된 스킬의 이름")
|
||||
scale: float = Field(..., description="스킬 크기 (수치)")
|
||||
damage: float = Field(..., description="스킬 데미지 (수치)")
|
||||
speed: float = Field(..., description="스킬 투사체 속도 또는 발동 속도 (수치)")
|
||||
visual_effect: str = Field(..., description="유니티 이펙트 종류 (예: fire, ice, electric, physical, poison 등)")
|
||||
skill_type: str = Field(..., description="스킬 형태 (예: projectile, splash, buff, aura 등)")
|
||||
description: str = Field(..., description="스킬 설명")
|
||||
@@ -0,0 +1,217 @@
|
||||
import json
|
||||
import re
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
from backend.ai.llm import hugging_llm
|
||||
from backend.repository.models import AncestorDeath
|
||||
from backend.schemas.game_schema import AncestorDeathCreate, SkillCreateResponse
|
||||
|
||||
# 1. 선조 사망 기록 등록
|
||||
def create_death_history(db: Session, death_data: AncestorDeathCreate):
|
||||
db_death = AncestorDeath(
|
||||
name=death_data.name,
|
||||
generation=death_data.generation,
|
||||
cause_of_death=death_data.cause_of_death,
|
||||
floor=death_data.floor
|
||||
)
|
||||
db.add(db_death)
|
||||
db.commit()
|
||||
db.refresh(db_death)
|
||||
return db_death
|
||||
|
||||
# 2. 문지기 NPC 조언 대사 생성
|
||||
def generate_npc_dialogue(db: Session, current_generation: int) -> str:
|
||||
# 현재 세대 이전(부모 세대들)의 사망 기록 중 최근 3개를 가져옴
|
||||
ancestors = (
|
||||
db.query(AncestorDeath)
|
||||
.filter(AncestorDeath.generation < current_generation)
|
||||
.order_by(desc(AncestorDeath.generation), desc(AncestorDeath.created_at))
|
||||
.limit(3)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not ancestors:
|
||||
# 선조 기록이 없는 최초 세대인 경우
|
||||
ancestor_history = "이전 선조들의 기록이 존재하지 않는 최초의 도전자입니다."
|
||||
else:
|
||||
history_lines = []
|
||||
for a in ancestors:
|
||||
# 부모, 조부모 등의 호칭 정리
|
||||
diff = current_generation - a.generation
|
||||
relation = "선조"
|
||||
if diff == 1:
|
||||
relation = "아버지(혹은 어머니)"
|
||||
elif diff == 2:
|
||||
relation = "할아버지(혹은 할머니)"
|
||||
elif diff == 3:
|
||||
relation = "증조할아버지(혹은 증조할머니)"
|
||||
|
||||
history_lines.append(
|
||||
f"- {relation} '{a.name}' (세대: {a.generation}대): 던전 {a.floor}층에서 '{a.cause_of_death}'에 의해 사망함."
|
||||
)
|
||||
ancestor_history = "\n".join(history_lines)
|
||||
|
||||
# 늙은 문지기 NPC 프롬프트 정의
|
||||
npc_prompt = """당신은 무한 반복 로그라이크 게임의 노련하고 퉁명스러운 던전 문지기 NPC '발두르'입니다.
|
||||
새로 던전에 도전하려는 플레이어(자손)에게 그의 선조들의 죽음을 기반으로 뼈 때리거나 진지한 조언을 해줍니다.
|
||||
|
||||
[성격 및 말투 지침]
|
||||
- 늙고 많은 도전자들의 죽음을 지켜본 노련한 문지기입니다.
|
||||
- 반말(낮춤말)을 사용하며, 말투는 다소 퉁명스럽고 거칠지만 속으로는 플레이어가 살아남기를 바라는 따뜻한 츤데레 톤입니다.
|
||||
- 너무 구구절절 말하지 말고, 1~2문장의 핵심 조언 대사로만 답하세요. (대답 이외의 부가 설명은 절대 작성하지 마세요)
|
||||
|
||||
[이전 선조들의 사망 기록]
|
||||
{ancestor_history}
|
||||
|
||||
현재 이 게임에 도전하는 플레이어는 {current_generation}대 자손입니다.
|
||||
위 선조들의 구체적인 사망 원인(예: 가시 함정, 특정 몬스터 등)을 직접적으로 언급하며, 이번 도전에서 같은 실수를 반복하지 않도록 주의를 주는 대사를 생성하세요.
|
||||
"""
|
||||
|
||||
prompt_template = ChatPromptTemplate.from_template(npc_prompt)
|
||||
chain = prompt_template | hugging_llm | StrOutputParser()
|
||||
|
||||
result = chain.invoke({
|
||||
"ancestor_history": ancestor_history,
|
||||
"current_generation": current_generation
|
||||
})
|
||||
|
||||
return result.strip()
|
||||
|
||||
# LLM 출력에서 JSON 파싱하는 헬퍼 함수
|
||||
def _parse_json_from_llm(text: str) -> dict:
|
||||
# ```json ... ``` 형식 추출 시도
|
||||
match = re.search(r"```json\s*({.*?})\s*```", text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 일반 { ... } 중괄호 내용 추출 시도
|
||||
match = re.search(r"({.*})", text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 텍스트 그대로 파싱 시도
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"LLM 응답에서 JSON을 파싱하지 못했습니다: {text}")
|
||||
|
||||
# 3. AI 기반 스킬 생성 및 밸런싱
|
||||
def generate_ai_skill(prompt: str, player_level: int) -> SkillCreateResponse:
|
||||
# 1. 플레이어 레벨에 따른 스탯 버젯(Budget) 결정
|
||||
# 예: 레벨 1 -> 8, 레벨 5 -> 16, 레벨 10 -> 26 등
|
||||
budget = 6.0 + player_level * 2.0
|
||||
max_damage = budget - 2.0 # 데미지는 최대 버젯에서 다른 능력치 최소값을 뺀 값
|
||||
|
||||
# 스킬 생성 프롬프트 정의
|
||||
skill_prompt = """당신은 플레이어의 입력을 바탕으로 게임 속성과 스탯이 잘 분배된 전투 스킬을 설계하는 AI 게임 기획자입니다.
|
||||
플레이어의 한글 텍스트 스킬 설명(또는 구호)을 바탕으로, 요구된 JSON 형식에 따라 스킬 데이터를 생성하십시오.
|
||||
|
||||
[플레이어 요청]
|
||||
"{prompt}"
|
||||
|
||||
[게임 밸런스 제약 조건]
|
||||
- 이 스킬의 세 가지 수치 (scale, damage, speed)의 합은 정확히 {budget} 이하이어야 합니다.
|
||||
- 각 스탯의 유효 범위:
|
||||
- scale (스킬 크기/범위): 최소 0.5, 최대 10.0 (광역 스킬일수록 높게 배분)
|
||||
- damage (스킬 피해량): 최소 1.0, 최대 {max_damage} (단일 강공격일수록 높게 배분)
|
||||
- speed (스킬 속도/투사체 속도): 최소 0.5, 최대 10.0 (신속한 공격일수록 높게 배분)
|
||||
- 플레이어의 요청 분위기에 맞게 이 세 가지 수치를 적절히 배분하십시오.
|
||||
- visual_effect 속성에는 다음 중 가장 적절한 하나를 선택해 넣으십시오:
|
||||
["fire", "ice", "electric", "physical", "poison", "dark", "light"]
|
||||
- skill_type 속성에는 다음 중 가장 적절한 하나를 선택해 넣으십시오:
|
||||
["projectile", "splash", "buff", "aura", "strike"]
|
||||
|
||||
[반드시 준수할 출력 JSON 형식]
|
||||
설명 없이 오직 아래 형식의 JSON 데이터 하나만 출력하세요. 다른 텍스트는 일체 허용되지 않습니다.
|
||||
{{
|
||||
"skill_name": "스킬 명칭",
|
||||
"scale": 3.0,
|
||||
"damage": 5.0,
|
||||
"speed": 2.0,
|
||||
"visual_effect": "선택된 이펙트",
|
||||
"skill_type": "선택된 스킬 타입",
|
||||
"description": "스킬에 대한 1줄 요약 설명"
|
||||
}}
|
||||
"""
|
||||
|
||||
prompt_template = ChatPromptTemplate.from_template(skill_prompt)
|
||||
chain = prompt_template | hugging_llm | StrOutputParser()
|
||||
|
||||
raw_response = chain.invoke({
|
||||
"prompt": prompt,
|
||||
"budget": budget,
|
||||
"max_damage": max_damage
|
||||
})
|
||||
|
||||
# 2. JSON 파싱
|
||||
try:
|
||||
skill_data = _parse_json_from_llm(raw_response)
|
||||
except Exception as e:
|
||||
# 파싱 실패 시 기본 안전 장치(Fallback) 제공
|
||||
skill_data = {
|
||||
"skill_name": f"임시 {prompt.split()[0] if prompt.split() else '스킬'}",
|
||||
"scale": 1.0,
|
||||
"damage": float(player_level * 2),
|
||||
"speed": 1.0,
|
||||
"visual_effect": "physical",
|
||||
"skill_type": "strike",
|
||||
"description": "마력 흐름이 불안정하여 생성된 임시 스킬입니다."
|
||||
}
|
||||
|
||||
# 3. 백엔드 자체 밸런싱 강제 규칙 (LLM 오작동 방지용 검증/보정 파이프라인)
|
||||
# 3-1. 수치 유효 범위 체크 및 캐스팅
|
||||
try:
|
||||
scale = max(0.5, min(10.0, float(skill_data.get("scale", 1.0))))
|
||||
damage = max(1.0, min(max_damage, float(skill_data.get("damage", 1.0))))
|
||||
speed = max(0.5, min(10.0, float(skill_data.get("speed", 1.0))))
|
||||
except (ValueError, TypeError):
|
||||
scale, damage, speed = 1.0, float(player_level * 2), 1.0
|
||||
|
||||
# 3-2. 총합 버젯 검증 및 강제 보정 (Normalize)
|
||||
total_stat = scale + damage + speed
|
||||
if total_stat > budget:
|
||||
# 초과할 경우 비율에 따라 스탯을 보정(정규화)하여 버젯 이하로 조절
|
||||
ratio = budget / total_stat
|
||||
scale = round(scale * ratio, 1)
|
||||
damage = round(damage * ratio, 1)
|
||||
speed = round(speed * ratio, 1)
|
||||
|
||||
# 보정 후 정밀도 오차 등으로 아주 미세하게 budget을 넘을 경우 소수점 조정
|
||||
total_stat = scale + damage + speed
|
||||
if total_stat > budget:
|
||||
damage = round(damage - (total_stat - budget), 1)
|
||||
else:
|
||||
# 소수점 1자리로 반올림 정리
|
||||
scale = round(scale, 1)
|
||||
damage = round(damage, 1)
|
||||
speed = round(speed, 1)
|
||||
|
||||
# 3-3. 문자열 검증 및 디폴트
|
||||
valid_effects = ["fire", "ice", "electric", "physical", "poison", "dark", "light"]
|
||||
valid_types = ["projectile", "splash", "buff", "aura", "strike"]
|
||||
|
||||
visual_effect = skill_data.get("visual_effect", "physical").lower()
|
||||
if visual_effect not in valid_effects:
|
||||
visual_effect = "physical"
|
||||
|
||||
skill_type = skill_data.get("skill_type", "strike").lower()
|
||||
if skill_type not in valid_types:
|
||||
skill_type = "strike"
|
||||
|
||||
return SkillCreateResponse(
|
||||
skill_name=skill_data.get("skill_name", "이름 없는 기술"),
|
||||
scale=scale,
|
||||
damage=damage,
|
||||
speed=speed,
|
||||
visual_effect=visual_effect,
|
||||
skill_type=skill_type,
|
||||
description=skill_data.get("description", "신비로운 에너지가 담긴 기술입니다.")
|
||||
)
|
||||
@@ -1,8 +0,0 @@
|
||||
from backend.ai.llm import watson_llm
|
||||
|
||||
# LLM 모델 통신
|
||||
# 데이터베이스 통신
|
||||
|
||||
def question_and_answer(question):
|
||||
response = watson_llm.invoke(question)
|
||||
return response.content
|
||||
Binary file not shown.
Reference in New Issue
Block a user