From 9ca7007c79e0f1e424b849e0f18470ab1bbd6a38 Mon Sep 17 00:00:00 2001 From: cooney Date: Wed, 24 Jun 2026 17:42:16 +0900 Subject: [PATCH] =?UTF-8?q?ai=20=EA=B8=B0=EB=B0=98=20=EB=AC=B8=EC=A7=80?= =?UTF-8?q?=EA=B8=B0=20npc=EC=9D=98=20=EC=A1=B0=EC=96=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=84=ED=88=AC=20=EC=8A=A4=ED=82=AC=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai-server/backend/ai/embedding.py | 5 +- ai-server/backend/ai/local_llm.py | 6 - ai-server/backend/main.py | 32 ++- ai-server/backend/repository/db_init.py | 4 +- ai-server/backend/repository/models.py | 17 ++ ai-server/backend/routers/game_router.py | 26 +++ ai-server/backend/routers/npc_router.py | 13 -- ai-server/backend/routers/skill_router.py | 13 -- ai-server/backend/schemas/basic_schema.py | 4 - ai-server/backend/schemas/game_schema.py | 39 ++++ ai-server/backend/services/game_service.py | 217 +++++++++++++++++++++ ai-server/backend/services/llm_service.py | 8 - ai-server/db/roguelike.db | Bin 0 -> 8192 bytes 13 files changed, 314 insertions(+), 70 deletions(-) delete mode 100644 ai-server/backend/ai/local_llm.py create mode 100644 ai-server/backend/repository/models.py create mode 100644 ai-server/backend/routers/game_router.py delete mode 100644 ai-server/backend/routers/npc_router.py delete mode 100644 ai-server/backend/routers/skill_router.py delete mode 100644 ai-server/backend/schemas/basic_schema.py create mode 100644 ai-server/backend/schemas/game_schema.py create mode 100644 ai-server/backend/services/game_service.py delete mode 100644 ai-server/backend/services/llm_service.py create mode 100644 ai-server/db/roguelike.db diff --git a/ai-server/backend/ai/embedding.py b/ai-server/backend/ai/embedding.py index 0ac04b9..4f1a98a 100644 --- a/ai-server/backend/ai/embedding.py +++ b/ai-server/backend/ai/embedding.py @@ -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") \ No newline at end of file +) \ No newline at end of file diff --git a/ai-server/backend/ai/local_llm.py b/ai-server/backend/ai/local_llm.py deleted file mode 100644 index b0b32ee..0000000 --- a/ai-server/backend/ai/local_llm.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/ai-server/backend/main.py b/ai-server/backend/main.py index a660c99..d065b30 100644 --- a/ai-server/backend/main.py +++ b/ai-server/backend/main.py @@ -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) \ No newline at end of file +app.include_router(game_router) \ No newline at end of file diff --git a/ai-server/backend/repository/db_init.py b/ai-server/backend/repository/db_init.py index b0bcbee..640b659 100644 --- a/ai-server/backend/repository/db_init.py +++ b/ai-server/backend/repository/db_init.py @@ -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) diff --git a/ai-server/backend/repository/models.py b/ai-server/backend/repository/models.py new file mode 100644 index 0000000..22dd22a --- /dev/null +++ b/ai-server/backend/repository/models.py @@ -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) + + diff --git a/ai-server/backend/routers/game_router.py b/ai-server/backend/routers/game_router.py new file mode 100644 index 0000000..44f235c --- /dev/null +++ b/ai-server/backend/routers/game_router.py @@ -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) diff --git a/ai-server/backend/routers/npc_router.py b/ai-server/backend/routers/npc_router.py deleted file mode 100644 index 5583b30..0000000 --- a/ai-server/backend/routers/npc_router.py +++ /dev/null @@ -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} \ No newline at end of file diff --git a/ai-server/backend/routers/skill_router.py b/ai-server/backend/routers/skill_router.py deleted file mode 100644 index 5583b30..0000000 --- a/ai-server/backend/routers/skill_router.py +++ /dev/null @@ -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} \ No newline at end of file diff --git a/ai-server/backend/schemas/basic_schema.py b/ai-server/backend/schemas/basic_schema.py deleted file mode 100644 index feb36ee..0000000 --- a/ai-server/backend/schemas/basic_schema.py +++ /dev/null @@ -1,4 +0,0 @@ -from pydantic import BaseModel - -class QuestionRequest(BaseModel): - question : str \ No newline at end of file diff --git a/ai-server/backend/schemas/game_schema.py b/ai-server/backend/schemas/game_schema.py new file mode 100644 index 0000000..cde1fed --- /dev/null +++ b/ai-server/backend/schemas/game_schema.py @@ -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="스킬 설명") diff --git a/ai-server/backend/services/game_service.py b/ai-server/backend/services/game_service.py new file mode 100644 index 0000000..3efa5fe --- /dev/null +++ b/ai-server/backend/services/game_service.py @@ -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", "신비로운 에너지가 담긴 기술입니다.") + ) diff --git a/ai-server/backend/services/llm_service.py b/ai-server/backend/services/llm_service.py deleted file mode 100644 index 448a540..0000000 --- a/ai-server/backend/services/llm_service.py +++ /dev/null @@ -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 diff --git a/ai-server/db/roguelike.db b/ai-server/db/roguelike.db new file mode 100644 index 0000000000000000000000000000000000000000..b228d53999d749bde4911651ffd121921311aece GIT binary patch literal 8192 zcmeI#Jx|*}7zgk>6Ddlh64a&Zvo#SUa{Q7&y3`O0B0}04w^GCq8FHbKAeJ1{xk@_} z!N7uG02B!j88T$(kVy0k)yH?}MJT)kq)u$lBcJT^o&S3L+ivw!vF3vo4>puO7mG6xjWnEX244(oVac%Kx}12lxXBvKbNss7c=p>3XQ#<3Ztb?CyOgv% z7E{}DUGMn>Py7q3R>V<0FS@r2E9RpHK3atp!z#nFS%!GMnusQbXY+}eOk#srQ{1pn z00mG01yBG5Pyhu`00mG01yBG5gn%&-CJCCP20uFpE`JBTW6=1|eu)3-@ZD~(cLBWB z4X#c@imYUkawe&0kj~~*Ehnc_>Rd*d)$>xAyrues2t_1FLHC*;9Y8Q>^L8Kj`5%6| zFFj1jH^$bBPek@C0qo$sB%&0&YP4~KEL-8_j=wUjJkl(~N- CL$GH6 literal 0 HcmV?d00001