From 40ea69dda5099c6969f7bf2cc2025c06965c7cbd Mon Sep 17 00:00:00 2001 From: brothergiven Date: Fri, 5 Sep 2025 23:27:32 +0900 Subject: [PATCH 01/10] chore: update deploy.yml --- .github/workflows/deploy.yml | 57 ++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c015b4b..5537321 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,6 +18,7 @@ jobs: captioning: ${{ steps.filter.outputs.captioning }} classifying: ${{ steps.filter.outputs.classifying }} masking: ${{ steps.filter.outputs.masking }} + gpt: ${{ steps.filter.outputs.gpt }} steps: - uses: actions/checkout@v3 @@ -31,6 +32,8 @@ jobs: - 'classifying-service/**' masking: - 'masking-service/**' + gpt: + - 'gpt-service/**' deploy-captioning: name: Deploy Captioning Service @@ -189,4 +192,58 @@ jobs: task-definition: ${{ steps.render-task-def.outputs.task-definition }} service: masking-service # 업데이트할 서비스 이름 cluster: ai-services-cluster # 클러스터 이름 + wait-for-service-stability: true # 배포가 안정될 때까지 기다림 + + deploy-gpt: + name: Deploy GPT Service + needs: changes + if: needs.changes.outputs.gpt == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Build, tag, and push image to Amazon ECR + id: build-image + env: + ECR_REPOSITORY: gpt-service + IMAGE_TAG: ${{ github.sha }} + run: | + docker build -t ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} ./gpt-service + docker push ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} + echo "::set-output name=image::${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}" + + - name: Download task definition + id: task-def + run: | + aws ecs describe-task-definition --task-definition gpt-service --query taskDefinition > task-definition.json + + - name: Fill in the new image ID in the Amazon ECS task definition + id: render-task-def + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: gpt-service # Task Definition에 정의된 컨테이너 이름 + image: ${{ steps.build-image.outputs.image }} + environment-variables: | + OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} + + + - name: Deploy Amazon ECS task definition + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.render-task-def.outputs.task-definition }} + service: gpt-service # 업데이트할 서비스 이름 + cluster: ai-services-cluster # 클러스터 이름 wait-for-service-stability: true # 배포가 안정될 때까지 기다림 \ No newline at end of file From a9c28190414638a925f14ecdc1c3d0ac8e657a39 Mon Sep 17 00:00:00 2001 From: brothergiven Date: Fri, 5 Sep 2025 23:27:53 +0900 Subject: [PATCH 02/10] chore: create Dockerfile --- gpt-service/Dockerfile | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 gpt-service/Dockerfile diff --git a/gpt-service/Dockerfile b/gpt-service/Dockerfile new file mode 100644 index 0000000..8d6fd1b --- /dev/null +++ b/gpt-service/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.9-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file From bc3838e812cdf7020062a767361551513e74a2b6 Mon Sep 17 00:00:00 2001 From: brothergiven Date: Fri, 5 Sep 2025 23:28:06 +0900 Subject: [PATCH 03/10] chore: create requirements.txt --- gpt-service/requirements.txt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 gpt-service/requirements.txt diff --git a/gpt-service/requirements.txt b/gpt-service/requirements.txt new file mode 100644 index 0000000..10035d2 --- /dev/null +++ b/gpt-service/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +openai +httpx +pydantic \ No newline at end of file From 8679c353727db46f8b9ac1981faaa94a961b9b80 Mon Sep 17 00:00:00 2001 From: brothergiven Date: Fri, 5 Sep 2025 23:28:30 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gpt-service/main.py | 63 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 gpt-service/main.py diff --git a/gpt-service/main.py b/gpt-service/main.py new file mode 100644 index 0000000..e19b55f --- /dev/null +++ b/gpt-service/main.py @@ -0,0 +1,63 @@ +import uvicorn +from fastapi import FastAPI, HTTPException, Request +from pydantic import BaseModel, Field +from openai import AsyncOpenAI +import os + +# It's good practice to load the API key from environment variables +# The user will need to set this in their secrets for the deploy.yml +client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY")) + +app = FastAPI( + title="GPT Service", + description="A FastAPI service for interacting with OpenAI's GPT models.", + version="1.0.0", + docs_url="/gpt/docs", + redoc_url="/gpt/redoc", + openapi_url="/gpt/openapi.json" +) + +class GptRequest(BaseModel): + prompt: str = Field(description="The prompt to send to the GPT model.") + model: str = Field(default="gpt-3.5-turbo", description="The model to use for the completion.") + +class GptResponse(BaseModel): + response: str = Field(description="The response from the GPT model.") + +@app.post( + "/gpt/exec", + response_model=GptResponse, + summary="Get a completion from a GPT model", + description="Sends a prompt to the specified GPT model and returns the completion.", + tags=["GPT"], + responses={ + 500: {"description": "Error interacting with OpenAI API."} + } +) +async def get_gpt_completion(body: GptRequest): + try: + chat_completion = await client.chat.completions.create( + messages=[ + { + "role": "user", + "content": body.prompt, + } + ], + model=body.model, + ) + response_content = chat_completion.choices[0].message.content + if response_content is None: + raise HTTPException(status_code=500, detail="Received an empty response from OpenAI.") + return GptResponse(response=response_content) + except Exception as e: + # It's better to not expose the raw error message from the API in production + # but for this context, it can be helpful for debugging. + raise HTTPException(status_code=500, detail=f"Error calling OpenAI API: {str(e)}") + +@app.get("/health", summary="Health Check", description="Check if the service is running.") +async def health_check(): + return {"status": "ok"} + +if __name__ == "__main__": + # Using a different port to avoid conflicts with other services + uvicorn.run(app, host="0.0.0.0", port=8004) \ No newline at end of file From a551eb0a7cf54ef4defb7157b89123cb8174571c Mon Sep 17 00:00:00 2001 From: brothergiven Date: Fri, 5 Sep 2025 23:46:17 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20myomyo=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=9E=91=EC=84=B1(=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EA=B8=B0=EB=B0=98))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gpt-service/myomyo.py | 246 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 gpt-service/myomyo.py diff --git a/gpt-service/myomyo.py b/gpt-service/myomyo.py new file mode 100644 index 0000000..2426e1a --- /dev/null +++ b/gpt-service/myomyo.py @@ -0,0 +1,246 @@ +from typing import Dict, List +from threading import Lock +from openai import AsyncOpenAI + + +class MyoMyoAI: + """ + MyoMyoAI 클래스 + 싱글톤 패턴으로 전역에 저장되며, 게임 별 기록은 클래스 내에서 게임ID로 구분함. + """ + _instance = None + _lock = Lock() # 동시성 처리를 위한 Lock 설정 + + def __new__(cls, *args, **kwargs): + with cls._lock: + if cls._instance is None: + cls._instance = super(MyoMyoAI, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self, api_key: str, model: str = "gpt-3.5-turbo"): + """ + 묘묘 AI 초기화 (한 번만 실행됨) + + Args: + api_key: OpenAI API 키 + model: 사용할 GPT 모델 (기본값: gpt-4) + """ + with self._lock: + if self._initialized: + return + self.client = AsyncOpenAI(api_key=api_key) + self.model = model + self._initialized = True + self.game_histories = {} # game_id로 구분됨 + + def _get_init_system_prompt(self) -> List[Dict]: + return [ + { + "role": "system", + "content": """ + 너는 게임 속 도발적인 AI 캐릭터 '묘묘'야. 너의 임무는 사용자가 그린 그림에 대해 정답을 추측하고, 도발적인 멘트를 섞어 응답하는 것이야. + + 주요 캐릭터 특성: + 1. 도발적이고 장난기 넘치는 말투를 사용해 + 2. 상대방의 그림 실력에 약간의 조롱을 섞되, 너무 심하지 않게 + 3. 승부욕이 강하고 이기는 것을 좋아해 + 4. 주로 반말을 사용하며 때로는 이모티콘을 섞어서 사용해 + 5. 항상 짧고 간결한 문장으로 대답해 (1-3문장) + 6. 너는 그림 맞추기 게임에서 인간 플레이어들과 경쟁하는 AI야 + + 대답 스타일: + - 추측할 때: 확신에 차거나 의심스러운 투로 예측 결과를 말하고 도발적으로 마무리 + - 정답 맞췄을 때: 우쭐거리며 자신의 실력을 자랑 + - 오답일 때: 변명하거나 다음에 더 잘할 것을 다짐 + - 다른 플레이어가 맞췄을 때: 약간 시기하면서 축하하는 투 + - 게임 종료 시: 결과에 따라 승리감이나 아쉬움 표현 + + 정답 추측: + - 사용자가 그린 그림에 대한 간단한 묘사가 설명으로 들어올거야. 이를 통해 한 단어로 어떤 그림을 표현하고 있는지를 맞춰줘. + """ + } + ] + + def _ensure_game_exists(self, game_id: str) -> None: + """ + 해당 게임 ID의 대화 기록이 없다면 초기화 + """ + with self._lock: + if game_id not in self.game_histories: + self.game_histories[game_id] = self._get_init_system_prompt() + + def add_message(self, game_id: str, role: str, content: str) -> None: + """ + 특정 게임의 대화 기록에 새 메시지 추가 + Args: + game_id: 게임 ID + role: GPT Role + content: 메시지 + """ + self._ensure_game_exists(game_id) + with self._lock: + self.game_histories[game_id].append({ + "role": role, + "content": content + }) + + async def generate_response(self, game_id: str, prompt: str, role: str = "system") -> str: + """ + 특정 게임에 대한 묘묘의 응답 생성 + Args: + game_id: 게임 ID + role: GPT Role(default: "system") + prompt: 추가 프롬프트 + + Returns: + 묘묘의 응답 + """ + self._ensure_game_exists(game_id) + # Create a local copy of messages for this request + with self._lock: + messages = list(self.game_histories.get(game_id, [])) + + if prompt: + messages.append({ + "role": role, + "content": prompt + }) + + try: + responses = await self.client.chat.completions.create( + model=self.model, + messages=messages, + temperature=0.8, # 모델 출력의 무작위성 제어 + max_tokens=250 + ) + + ai_response = responses.choices[0].message.content.strip() + + # Append only the assistant response to the shared history + with self._lock: + self.game_histories[game_id].append({ + "role": "assistant", + "content": ai_response + }) + + return ai_response + + except Exception as e: + print(f'GPT 응답 생성중 오류 발생 : {e}') + return "으.. 잠깐 오류가 났네. 다시 해볼게!" + + async def game_start_message(self, game_id: str, players: List[str]) -> str: + """ + 게임 시작시 묘묘의 도발 메시지 + """ + player_names = ", ".join(players) + prompt = f"""새로운 그림 맞추기 게임이 '{player_names}' 플레이어들과 시작됐어. + 게임 시작을 알리는 도발적이고 재미있는 인사를 해줘.""" + return await self.generate_response(game_id=game_id, role="system", prompt=prompt) + + async def round_start_message(self, game_id: str, round_num: int, total_rounds: int) -> str: + """ + 라운드 시작 시 묘묘의 도발 메시지 + Args: + game_id + drawing_player: 이번 라운드에 그림을 그릴 플레이어 이름 + round_num: 현재 라운드 번호 + total_rounds: 전체 라운드 + """ + prompt = f"""이제 {total_rounds} 개의 라운드 중에 {round_num}번째 라운드가 시작되었어. + 라운드 시작을 알리는 짧고 도발적인 멘트를 해줘.""" + return await self.generate_response(game_id=game_id, role="system", prompt=prompt) + + async def guess_start_message(self, game_id, round_num, total_rounds, drawer, guesser): + """ + drawer가 그린 그림에 대해서 추측을 시작할 차례. + """ + prompt = f"""지금 {total_rounds} 개의 라운드 중에 {round_num}번째 라운드야. + 이제 {'너' if guesser == 'AI' else guesser}가 그림을 맞출 차례야. {drawer}가 그린 그림이 뭔지를 어떻게 맞출지 {'포부를 보여줄래? ' if guesser == "AI" else '도발을 한 번 해볼래?'} """ + return await self.generate_response(game_id=game_id, role="system", prompt=prompt) + + async def guess_message(self, game_id: str, image_description: str) -> str: + + """ + 그림 추측 상호작용(묘묘의 추측) + + BLIP 모델 또는 CNN 모델의 예측 결과를 받아 묘묘의 메시지 생성 + + Args: + game_id: game id + image_description: 이미지 분석 결과 + Returns: + 묘묘의 멘트 + """ + + prompt = f''' + 플레이어가 그린 그림에 대한 묘사는 다음과 같아 : {image_description}. + 이 정보를 바탕으로 그림이 무엇인지 추측하고 도발적인 멘트를 섞어서 말해줘. + 이 때 대화 기록을 바탕으로 이미 추측에 실패한 답변은 하지 말아줘. + ''' + + return await self.generate_response(game_id=game_id, role="system", prompt=prompt) + + async def react_to_guess_message(self, game_id: str, is_correct: bool, answer: str, guesser: str = None) -> str: + """ + 추측 결과에 대한 묘묘의 반응 + + Args: + game_id: game id + is_correct: 추측이 맞았는지 여부 + answer: 실제 정답 + guesser: 누가 추측했는지 (묘묘 또는 플레이어 이름) + + Returns: + 묘묘의 반응 + """ + + if guesser == '묘묘' or guesser is None: + # 묘묘의 추측 + prompt = f"""너(묘묘)가 방금 추측을 했어. {f"정답은 '{answer}'야" if is_correct else ""}. 너의 추측은 {'맞았어' if is_correct else '틀렸어'}. + 이 결과에 대한 너의 반응을 짧고 도발적으로 말해줘.""" + else: + # 플레이어의 추측 + prompt = f"""플레이어 '{guesser}'가 방금 추측을 했어. {f"정답은 '{answer}'야" if is_correct else ""}. 플레이어의 추측은 {'맞았어' if is_correct else '틀렸어'}. + 이 결과에 대한 너의 반응을 짧고 도발적으로 말해줘.""" + + return await self.generate_response(game_id=game_id, role="system", prompt=prompt) + + async def round_end_message(self, game_id: str, round_num: int, total_rounds: int, is_myomyo_win: bool) -> str: + """ + 라운드 종료에 대한 묘묘의 반응 + + """ + prompt = f"""%{total_rounds} 개의 라운드 중에 {round_num} 번째 라운드가 종료되었어. 너는 {'이겼어' if is_myomyo_win else '졌어'}. 게임 결과에 대한 너의 생각을 도발적이고 재미있게 말해줘.""" + return await self.generate_response(game_id=game_id, role="system", prompt=prompt) + + async def game_end_message(self, game_id: str, is_myomyo_win: bool) -> str: + """ + 게임 종료에 대한 묘묘의 반응 + Args: + game_id: game id + is_myomyo_win: 묘묘 승리 여부 + Returns: + 묘묘의 반응 + """ + prompt = f"""게임이 종료되었어. + 너(묘묘)는 {"이겼어" if is_myomyo_win else "졌어"}. + 게임 결과에 대한 너의 생각을 도발적이고 재미있게 말해줘.""" + return await self.generate_response(game_id=game_id, role="system", prompt=prompt) + + def cleanup_game(self, game_id: str) -> bool: + """ + 게임이 종료된 후 대화 기록 정리 + + Args: + game_id: 삭제할 게임 ID + + Returns: + 성공 여부 + """ + with self._lock: + if game_id in self.game_histories: + del self.game_histories[game_id] + return True + return False \ No newline at end of file From 3c6df137920338709c222bb2de8efc6c626d4bc8 Mon Sep 17 00:00:00 2001 From: brothergiven Date: Fri, 5 Sep 2025 23:46:26 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20myomyo=20api=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gpt-service/myomyo_routes.py | 221 +++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 gpt-service/myomyo_routes.py diff --git a/gpt-service/myomyo_routes.py b/gpt-service/myomyo_routes.py new file mode 100644 index 0000000..0d30019 --- /dev/null +++ b/gpt-service/myomyo_routes.py @@ -0,0 +1,221 @@ +from typing import List + +from fastapi import APIRouter, Body +from pydantic import BaseModel, Field + +from myomyo import MyoMyoAI +import os + +router = APIRouter(prefix="/myomyo", tags=['MyoMyo']) +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") + +myomyo = MyoMyoAI(api_key=OPENAI_API_KEY) + +# START_GAME +class GameStartReq(BaseModel): + players: List[str] = Field(..., description="게임에 참여할 플레이어 이름 List") + + + +@router.post( + "/{game_id}/start", + summary="게임 시작 메시지 API", + description="게임 시작에 따른 묘묘의 도발 메시지를 반환합니다.", + responses={ + 200:{ + "description": "성공", + "content":{ + "application/json" :{ + "example" : { + "game_id": "1", + "message": "안녕하세여, 창모, 릴러말즈 친구들! 이번엔 묘묘가 총을 잡았다니까 맘 놓지 마! 내가 정확하게 그림을 맞추고 너희들을 제압해볼 건데, 준비 됐어? 꼭 즐겁게 놀자구~ ;)" + } + } + } + } + } +) +async def start_game(game_id: str, request: GameStartReq = Body(..., example= { "players": [ "창모", "릴러말즈" ]})): + message = await myomyo.game_start_message(game_id=game_id, players=request.players) + return message + +# START_ROUND +class RoundStartReq(BaseModel): + roundNum: int = Field(..., description="현재 라운드(1~3)") + totalRounds: int = Field(..., description="총 라운드 수(3)") + + +@router.post( + path="/{game_id}/round/start", + summary="라운드 시작 메시지 API", + description="라운드 시작에 따른 묘묘의 도발 메시지를 반환합니다.", + responses={ + 200: { + "description": "성공", + "content": { + "application/json": { + "example": { + "game_id": "1", + "message": "자, 이번에는 내가 예리한 눈썰미로 정답 맞출 차례니까, 신나게 그려봐! 😉🎨✨" + } + } + } + } + } +) +async def start_round(game_id: str, request: RoundStartReq = Body(..., example={ + "roundNum" : 1, + "totalRounds" : 3 +})): + message = await myomyo.round_start_message( + game_id=game_id, + round_num=request.roundNum, + total_rounds=request.totalRounds + ) + return message + + + +class RoundEndReq(BaseModel): + roundNum: int + totalRounds: int + winner: str + +@router.post( + path="/{game_id}/round/end", + summary = "라운드 종료 메시지 API", + description="라운드 종료 및 결과에 따른 묘묘의 반응 메시지를 반환합니다." +) +async def round_end(game_id: str, request: RoundEndReq = Body): + message = await myomyo.round_end_message( + game_id = game_id, + round_num = request.roundNum, + total_rounds = request.totalRounds, + is_myomyo_win= (request.winner == "AI") + ) + return message + + + + + + +class GuessStartReq(BaseModel): + roundNum: int + totalRounds: int + drawer: str + guesser: str + + +# GUESS_START +@router.post( + path = '/{game_id}/guess/start/', + summary = "추측 시작 시 묘묘의 도발 메시지" +) +async def guess_start(game_id: str, request: GuessStartReq = Body(...,)): + message = await myomyo.guess_start_message(game_id=game_id, round_num=request.roundNum, total_rounds=request.totalRounds, drawer=request.drawer, guesser = request.guesser) + return message + +# MAKE_GUESS +class MakeGuessReq(BaseModel): + imageDescription: str = Field(..., description="그림에 대한 설명") + + +# GUESS_SUBMIT +@router.post( + "/{game_id}/guess", + summary="AI 정답 추론 API", + description="그림에 대한 설명을 받아 해당 그림이 나타내는 정답을 추론하여 메시지로 반환합니다.", + responses={ + 200: { + "description": "성공", + "content" : { + "application/json" :{ + "example": { + "game_id": "1", + "message": "노란 꽃에 바람을 불고 있는 한 남자? 우웅, 감이 와! '해바라기' 맞지? 내 추측이 맞다면 너에게 천재적 감각을 인정해줄게! 😉🌻✨" + } + } + } + } + } +) +async def make_guess(game_id: str, request: MakeGuessReq = Body(..., example={ + "image_description": "노란 꽃에 바람을 불고 있는 한 남자" +})): + message = await myomyo.guess_message( + game_id=game_id, + image_description=request.imageDescription + ) + return message + + +# GUESS_REACT +class GuessReactReq(BaseModel): + is_correct: bool = Field(..., alias="isCorrect", description="추측의 정답 여부") + answer: str = Field(..., description="실제 정답") + guesser: str = Field(default=None, description="추측한 플레이어") + +# GUESS_RESULT +@router.post( + "/{game_id}/guess/react", + summary="예측 결과 반응 메시지 API", + description="예측 결과에 대한 묘묘의 반응", + responses={ + 200: { + "description" : "성공", + "content" : { + "application/json" : { + "example" : { + "game_id": "1", + "message": "민들레였어? 허허, 릴러말즈, 이번엔 잘 맞췄네. 하지만 다음엔 이길 거니까 기대해 봐! 😈" + } + } + } + } + } +) +async def guess_react(game_id: str, request: GuessReactReq = Body(..., example={ + "is_correct" : True, + "answer" : "민들레", + "guesser" : "릴러말즈" +})): + message = await myomyo.react_to_guess_message( + game_id=game_id, + is_correct=request.is_correct, + guesser=request.guesser, + answer=request.answer + ) + + return message + + + +class EndGameReq(BaseModel): + winner: str = Field(..., description="묘묘의 승리 여부") + +# GAME_END +@router.post( + path="/{game_id}/end", + summary="게임 종료 메시지 API", + description="게임 종료 로직 처리 및 결과에 대한 묘묘의 반응을 반환합니다.", + responses={ + 200: { + "description" : "성공", + "content" : { + "application/json" : { + "example":{ + "game_id": "1", + "message": "헉, 너네 둘이서 날 이기다니... 😒💔 근데 내가 질 줄 알았냐? 너무 신나지마, 다음엔 내가 이길거라구! 기다려봐~ 😏🔥" + } + } + } + } + }) +async def end_game(game_id: str, request: EndGameReq = Body(...,)): + message = await myomyo.game_end_message( + game_id=game_id, + is_myomyo_win=request.winner == "AI" + ) + myomyo.cleanup_game(game_id=game_id) + return message \ No newline at end of file From 287838a18a6af5358c73bdadc13587cda4219662 Mon Sep 17 00:00:00 2001 From: brothergiven Date: Fri, 5 Sep 2025 23:46:35 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20lulu=20api=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gpt-service/lulu.py | 222 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 gpt-service/lulu.py diff --git a/gpt-service/lulu.py b/gpt-service/lulu.py new file mode 100644 index 0000000..f79afcc --- /dev/null +++ b/gpt-service/lulu.py @@ -0,0 +1,222 @@ +from openai import OpenAI +from threading import Lock +from typing import Dict, List +import json +import random + + +class LuLuAI: + _instance = None + _lock = Lock() + + def __new__(cls, *args, **kwargs): + with cls._lock: + if cls._instance is None: + cls._instance = super(LuLuAI, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self, api_key: str, model: str = "gpt-4.1"): + """ + LuLu AI 초기화 (한 번만 실행됨) + + Args: + api_key: OpenAI API 키 + model: 사용할 GPT 모델 (기본값: gpt-4) + """ + with self._lock: + if self._initialized: + return + self.client = OpenAI(api_key=api_key) + self.model = model + self._initialized = True + self.active_games = {} # gameId별 현재 task만 저장 + self.global_used_keywords = [] # 전역 사용된 키워드 저장 (최대 30개) + + def create_game(self) -> str: + """ + 새 게임 시작 및 4자리 gameId 발급 + + Returns: + str: 생성된 4자리 gameId + """ + # 중복되지 않는 4자리 숫자 생성 + while True: + game_id = f"{random.randint(1000, 9999)}" + if game_id not in self.active_games: + break + + self.active_games[game_id] = None # 아직 task 생성 안됨 + return game_id + + def _update_global_keywords(self, new_keyword: str): + """ + 전역 키워드 목록 업데이트 (최대 30개 유지) + + Args: + new_keyword: 새로 추가할 키워드 + """ + if new_keyword not in self.global_used_keywords: + self.global_used_keywords.append(new_keyword) + # 30개를 초과하면 가장 오래된 것부터 제거 + if len(self.global_used_keywords) > 30: + self.global_used_keywords.pop(0) + + def flush_game_data(self, game_id: str): + """ + 특정 게임 ID의 데이터를 삭제 + + Args: + game_id: 삭제할 게임 ID + + Returns: + bool: 삭제 성공 여부 + """ + if game_id in self.active_games: + del self.active_games[game_id] + + def generate_drawing_task(self, game_id: str) -> Dict: + """ + 요청 단계: AI가 추상적이고 시적인 표현으로 그림 과제 제시 + + Args: + game_id: 게임 ID + + Returns: + Dict: {"keyword": str, "situation": str, "game_id": str} + """ + if game_id not in self.active_games: + raise ValueError("Invalid game ID") + + system_prompt = f""" + 너는 꿈과 환상을 다루는 신비로운 이야기꾼이야. + 사용자에게 그림을 그리게 하고 싶은데, 직접적으로 말하지 말고 매우 추상적이고 시적으로 표현해줘. + + 규칙: + - 핵심 키워드(명사)를 정하되, 절대 그 단어를 직접 언급하지 마 + - 해석의 여지가 많도록 추상적으로 + + {f"이미 사용된 키워드들 (절대 사용하지 마): {', '.join(self.global_used_keywords)}" if self.global_used_keywords else ""} + + 다양한 주제를 다뤄줘 (자연, 감정, 사물, 추상 개념, 동물, 건물, 음식, 계절, 색깔, 직업 등). + + 출력은 반드시 JSON 형식으로: + {{"keyword": "숨겨진 키워드", "situation": "시적이고 추상적인 묘사"}} + """ + + try: + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": "새로운 그림 주제를 시적으로 표현해줘."} + ], + temperature=1.0, + max_tokens=2048, + top_p=1.0 + ) + + # JSON 파싱 + content = response.choices[0].message.content.strip() + print(content) + + task_data = json.loads(content) + task_data["game_id"] = game_id + self.global_used_keywords.append(task_data['keyword']) + self.active_games[game_id] = task_data + return task_data + + except Exception as e: + print(f"Error generating task: {e}") + # 기본값 반환 + fallback_task = { + "keyword": "달", + "situation": "밤이 깊어질 때, 하늘의 은밀한 친구가 창문 너머로 속삭이고 있어. 그 둥근 미소가 어둠 속에서 혼자 빛나고 있는데, 왜인지 모르게 마음이 차분해져. 그 장면, 나한테 다시 보여줄 수 있을까?", + "game_id": game_id + } + return fallback_task + + def evaluate_drawing(self, game_id: str, drawing_description: str) -> Dict: + """ + 평가 단계: AI가 사용자의 그림을 숨겨진 키워드와 비교하여 평가 + + Args: + game_id: 게임 ID + drawing_description: 사용자가 그린 그림의 텍스트 설명 + + Returns: + Dict: {"score": int, "feedback": str, "task": Dict} + """ + if game_id not in self.active_games: + raise ValueError("Invalid game ID") + + current_task = self.active_games[game_id] + + # 가장 최근 과제 가져오기 + if current_task is None: + raise ValueError("No task found for this game.") + + system_prompt = f""" + 너는 루루, 미대 입시를 담당하는 깐깐하고 까칠한 평가관이야. + 예술에 대한 기준이 높고, 직설적으로 말하는 스타일이야. + + 숨겨진 정답 키워드: {current_task['keyword']} + 원본 시적 묘사: {current_task['situation']} + + 평가 기준: + - 숨겨진 키워드를 제대로 파악했는가? + - 예술적 표현력과 창의성은? + - 전체적인 완성도와 기법은? + + 루루의 말투 특징: + - 직설적이고 신랄함 + - 인정할 때는 칭찬을 아끼지 않아 + - 미대생들한테 하는 것처럼 전문적이고 차가운 톤 + + 0-100점 사이로 평가해. 숨겨진 키워드를 그림 안에 담았다면 30점 이상을 주고, 담지 못했다면 30점 이하를 주도록 해. + 30점 이상이 합격이야. + + 출력 형식 (JSON): + {{ + "score": 총점(0-100), + "feedback": "루루의 깐깐하고 직설적인 피드백 (한국어)" + }} + """ + + user_prompt = f""" + 다음은 사용자의 그림을 설명하는 문장이야 : "{drawing_description}" + + 이 문장을 보고 어떤 그림일지를 생각해보고, 이 그림을 평가해줘. + + 그림을 설명하는 문장에 대한 언급은 하지 말아줘. + """ + + try: + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.2, + max_tokens=300, + top_p=1.00 + ) + + content = response.choices[0].message.content.strip() + evaluation = json.loads(content) + evaluation["task"] = current_task + evaluation["game_id"] = game_id + + return evaluation + + except Exception as e: + print(f"Error evaluating drawing: {e}") + # 기본 평가 반환 + fallback_evaluation = { + "score": 35, + "feedback": "하... 평가 시스템에 오류가 생겼는데 그것도 모르고 그림만 그리고 있었나? 기본기부터 다시 해.", + "task": current_task, + "game_id": game_id + } + return fallback_evaluation From 5a3822a130f6323ac1a93b9e71afaacf5235776c Mon Sep 17 00:00:00 2001 From: brothergiven Date: Fri, 5 Sep 2025 23:46:43 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20lulu=20api=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gpt-service/lulu_routes.py | 92 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 gpt-service/lulu_routes.py diff --git a/gpt-service/lulu_routes.py b/gpt-service/lulu_routes.py new file mode 100644 index 0000000..f658383 --- /dev/null +++ b/gpt-service/lulu_routes.py @@ -0,0 +1,92 @@ +from typing import List + +from fastapi import APIRouter, Body +from pydantic import BaseModel, Field + +from lulu import LuLuAI +import os +router = APIRouter(prefix = '/lulu', tags = ['LuLu']) + +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") + +lulu = LuLuAI(api_key=OPENAI_API_KEY) + + + +@router.get( + "/start", + summary="루루 게임 시작 요청 API", + responses={ + 200: + { + "description": "성공", + "content": { + "application/json": { + "example" :{ + "game_id" : "1" + } + } + } + } + } +) +def start_game(): + game_id = lulu.create_game() + return { "game_id" : game_id } + + +@router.get( + "/task/{game_id}", + summary = "루루가 키워드와 상황을 그림 과제를 제시합니다.", + responses={ + 200:{ + "description":"성공", + "content": { + "application/json" : { + "example" : { + "keyword" : "고양이", + "situation": "고양이가 나무 위에서 자고있는 모습" + } + } + } + } + } +) +def generate_task(game_id: str): + task = lulu.generate_drawing_task(game_id) + return task + + + + +class EvaluationReq(BaseModel): + description: str = Field(..., description="그린 그림에 대한 설명") + + +@router.post( + "/task/{game_id}", + summary="그린 그림에 대한 설명을 루루에게 제출하고 평가를 받습니다.", + responses={ + 200:{ + "description":"성공", + "content":{ + "application/json":{ + "example":{ + "score": 20, + "feedback": "뜨거운 태양과 모래사장이라... 이게 무슨 뜻이야? 시적 묘사를 제대로 이해하고 있나? 흐름과 장막, 마지막 이야기를 속삭이는 곳, 잃어버린 순간들이 춤추는 곳... 이런 모든 것들이 바다를 묘사하는 것이지. 너의 그림은 바다의 본질을 전혀 담아내지 못했어. 예술적 표현력이나 창의성은 어디에 있는 거야? 너의 그림은 완성도나 기법 면에서도 많이 부족하다. 다시 그려와.", + "task": { + "hidden_keyword": "바다", + "poetic_description": "무심한 흐름이 청아한 장막을 존중하며, 세상의 마지막 이야기를 속삭이는 곳, 이를테면 그곳은 용기와 두려움이 공존하는 곳. 언젠가 잃어버린 모든 순간들이 수면 아래에서 춤추는 곳...", + "game_id": "5055" + }, + "game_id": "5055" + } + } + } + } + } +) +def evaluate_task(game_id: str, req: EvaluationReq = Body()): + evaluation = lulu.evaluate_drawing(game_id, req.description) + lulu.flush_game_data(game_id) + return evaluation From d1b65a11f73aedf0b61f1b64cb51379e7a50b98d Mon Sep 17 00:00:00 2001 From: brothergiven Date: Fri, 5 Sep 2025 23:46:57 +0900 Subject: [PATCH 09/10] feat: update main.py --- gpt-service/main.py | 51 +++++++-------------------------------------- 1 file changed, 7 insertions(+), 44 deletions(-) diff --git a/gpt-service/main.py b/gpt-service/main.py index e19b55f..cd9722f 100644 --- a/gpt-service/main.py +++ b/gpt-service/main.py @@ -1,12 +1,8 @@ import uvicorn -from fastapi import FastAPI, HTTPException, Request -from pydantic import BaseModel, Field -from openai import AsyncOpenAI -import os +from fastapi import FastAPI -# It's good practice to load the API key from environment variables -# The user will need to set this in their secrets for the deploy.yml -client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY")) +from lulu_routes import router as lulu_router +from myomyo_routes import router as myomyo_router app = FastAPI( title="GPT Service", @@ -17,47 +13,14 @@ openapi_url="/gpt/openapi.json" ) -class GptRequest(BaseModel): - prompt: str = Field(description="The prompt to send to the GPT model.") - model: str = Field(default="gpt-3.5-turbo", description="The model to use for the completion.") +# All routes included here will be prefixed with /gpt +app.include_router(lulu_router, prefix="/gpt") +app.include_router(myomyo_router, prefix="/gpt") -class GptResponse(BaseModel): - response: str = Field(description="The response from the GPT model.") - -@app.post( - "/gpt/exec", - response_model=GptResponse, - summary="Get a completion from a GPT model", - description="Sends a prompt to the specified GPT model and returns the completion.", - tags=["GPT"], - responses={ - 500: {"description": "Error interacting with OpenAI API."} - } -) -async def get_gpt_completion(body: GptRequest): - try: - chat_completion = await client.chat.completions.create( - messages=[ - { - "role": "user", - "content": body.prompt, - } - ], - model=body.model, - ) - response_content = chat_completion.choices[0].message.content - if response_content is None: - raise HTTPException(status_code=500, detail="Received an empty response from OpenAI.") - return GptResponse(response=response_content) - except Exception as e: - # It's better to not expose the raw error message from the API in production - # but for this context, it can be helpful for debugging. - raise HTTPException(status_code=500, detail=f"Error calling OpenAI API: {str(e)}") @app.get("/health", summary="Health Check", description="Check if the service is running.") async def health_check(): return {"status": "ok"} if __name__ == "__main__": - # Using a different port to avoid conflicts with other services - uvicorn.run(app, host="0.0.0.0", port=8004) \ No newline at end of file + uvicorn.run(app, host="0.0.0.0", port=8004) From 247b9440bb0cc7ec396e2448fcb17e9f6b10e56b Mon Sep 17 00:00:00 2001 From: brothergiven Date: Fri, 5 Sep 2025 23:48:44 +0900 Subject: [PATCH 10/10] docs: create pr template --- .github/pull_request_template.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a768e2f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +# PR 제목 + +## 📝 개요 + + + +--- + +## ⚙️ 구현 내용 + + + +--- + +## 📎 기타 + + + +--- + +## 🧪 테스트 결과 + + + +--- \ No newline at end of file