Skip to content

Commit eba417c

Browse files
9keyyyyGukhee JoCopilot
authored
[NL-59] 로또 번호 추천 테이블 생성 및 프롬프트 작성, 번호 추천/조회 API 구현 (#9)
* fix: github action deploy image prune 명령 추가 * fix: 스크립트 실행 오류 수정 * feat: 로또 번호 추천 프롬프트 추가 * fix: 사주명식 - 가장 강한/약한 오행 계산 및 저장 추가 * fix: parser 오류 수정 * fix: 프롬프트 수정 * feat: 사용자별 로또 번호 추천/조회 API 생성 * feat: alembic migration * fix: typeddict -> base model * Update src/lotto/service.py Co-authored-by: Copilot <[email protected]> * Update src/lotto/service.py Co-authored-by: Copilot <[email protected]> * feat: 강한/상충되는 기운 필드 추가 --------- Co-authored-by: Gukhee Jo <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 1727c40 commit eba417c

File tree

16 files changed

+557
-61
lines changed

16 files changed

+557
-61
lines changed

.github/workflows/deploy.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,6 @@ jobs:
5050
5151
# 상태 확인
5252
docker compose -f docker-compose.dev.yml ps fastapi
53+
54+
# 사용되지 않는 이미지 정리
55+
docker system prune

docker-compose.dev.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ services:
2020
image: satto-registry.kr.ncr.ntruss.com/satto-server-fastapi:latest
2121
pull_policy: always
2222
container_name: fastapi-app
23-
depends_on:
24-
mysql:
25-
condition: service_healthy
23+
# depends_on:
24+
# mysql:
25+
# condition: service_healthy
2626
volumes:
2727
- ${WRITABLE_DIR}/logs/app:/data/logs/app
2828
command: >

migrations/scripts/insert_lotto_data.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
from sqlalchemy import select
1616
from sqlalchemy.ext.asyncio import AsyncSession
1717

18+
# 프로젝트 루트 Python 경로에 추가
19+
project_root = Path(__file__).parent.parent.parent
20+
if str(project_root) not in sys.path:
21+
sys.path.insert(0, str(project_root))
22+
1823
# .env 파일 로드
1924
from dotenv import load_dotenv
2025
load_dotenv()
@@ -199,4 +204,4 @@ async def main():
199204

200205

201206
if __name__ == "__main__":
202-
asyncio.run(main())
207+
asyncio.run(main())
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""add lotto_recommendations
2+
3+
Revision ID: de9ee05d86b4
4+
Revises: 05818a33a6e5
5+
Create Date: 2025-08-10 00:59:05.406541
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = 'de9ee05d86b4'
16+
down_revision: Union[str, Sequence[str], None] = '05818a33a6e5'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.create_table('lotto_recommendations',
25+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
26+
sa.Column('user_id', sa.String(length=255), nullable=False),
27+
sa.Column('round', sa.Integer(), nullable=False),
28+
sa.Column('content', sa.JSON(), nullable=False),
29+
sa.Column('created_at', sa.DateTime(), nullable=False),
30+
sa.Column('updated_at', sa.DateTime(), nullable=False),
31+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
32+
sa.PrimaryKeyConstraint('id')
33+
)
34+
# ### end Alembic commands ###
35+
36+
37+
def downgrade() -> None:
38+
"""Downgrade schema."""
39+
# ### commands auto generated by Alembic - please adjust! ###
40+
op.drop_table('lotto_recommendations')
41+
# ### end Alembic commands ###

src/four_pillars/common/calculator.py

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from datetime import datetime, date
22
from pathlib import Path
33
from typing import List, Tuple, Dict
4+
from collections import Counter
45

56
from src.four_pillars.entities.schemas import FourPillar
7+
from src.four_pillars.entities.enums import FiveElements
68

79

810
class FourPillarsCalculator:
@@ -14,6 +16,39 @@ class FourPillarsCalculator:
1416
def __init__(self):
1517
self._init_kanshi_data()
1618
self._init_setsuiri_data()
19+
self._init_five_elements_mapping()
20+
21+
def _init_five_elements_mapping(self):
22+
"""천간과 지지의 오행 매핑 초기화"""
23+
# 천간 오행 매핑
24+
self.jikkan_five_elements = {
25+
"甲": FiveElements.WOOD, # 갑 - 목
26+
"乙": FiveElements.WOOD, # 을 - 목
27+
"丙": FiveElements.FIRE, # 병 - 화
28+
"丁": FiveElements.FIRE, # 정 - 화
29+
"戊": FiveElements.EARTH, # 무 - 토
30+
"己": FiveElements.EARTH, # 기 - 토
31+
"庚": FiveElements.METAL, # 경 - 금
32+
"辛": FiveElements.METAL, # 신 - 금
33+
"壬": FiveElements.WATER, # 임 - 수
34+
"癸": FiveElements.WATER, # 계 - 수
35+
}
36+
37+
# 지지 오행 매핑
38+
self.jyunishi_five_elements = {
39+
"寅": FiveElements.WOOD, # 인 - 목
40+
"卯": FiveElements.WOOD, # 묘 - 목
41+
"巳": FiveElements.FIRE, # 사 - 화
42+
"午": FiveElements.FIRE, # 오 - 화
43+
"辰": FiveElements.EARTH, # 진 - 토
44+
"戌": FiveElements.EARTH, # 술 - 토
45+
"丑": FiveElements.EARTH, # 축 - 토
46+
"未": FiveElements.EARTH, # 미 - 토
47+
"申": FiveElements.METAL, # 신 - 금
48+
"酉": FiveElements.METAL, # 유 - 금
49+
"亥": FiveElements.WATER, # 해 - 수
50+
"子": FiveElements.WATER, # 자 - 수
51+
}
1752

1853
def _init_kanshi_data(self):
1954
"""60간지 배열과 해시 초기화"""
@@ -115,6 +150,40 @@ def _calculate_kanshi(
115150

116151
return [year_pillar, month_pillar, day_pillar, time_pillar]
117152

153+
def _analyze_five_elements(self, pillars: List[str]) -> Tuple[List[FiveElements], List[FiveElements]]:
154+
"""사주팔자의 오행 분석"""
155+
element_counts = Counter()
156+
157+
# 모든 오행을 0으로 초기화
158+
all_elements = [FiveElements.WOOD, FiveElements.FIRE, FiveElements.EARTH, FiveElements.METAL, FiveElements.WATER]
159+
for element in all_elements:
160+
element_counts[element] = 0
161+
162+
for pillar in pillars:
163+
if pillar is None:
164+
continue
165+
166+
# 천간과 지지 분리
167+
jikkan = pillar[0] # 첫 번째 글자는 천간
168+
jyunishi = pillar[1] # 두 번째 글자는 지지
169+
170+
# 천간 오행 추가
171+
if jikkan in self.jikkan_five_elements:
172+
element_counts[self.jikkan_five_elements[jikkan]] += 1
173+
174+
# 지지 오행 추가
175+
if jyunishi in self.jyunishi_five_elements:
176+
element_counts[self.jyunishi_five_elements[jyunishi]] += 1
177+
178+
# 가장 많은 오행과 가장 적은 오행 찾기
179+
max_count = max(element_counts.values())
180+
min_count = min(element_counts.values())
181+
182+
strong_elements = [element for element, count in element_counts.items() if count == max_count]
183+
weak_elements = [element for element, count in element_counts.items() if count == min_count]
184+
185+
return strong_elements, weak_elements
186+
118187
def calculate_four_pillars(self, birth_date: datetime) -> FourPillar:
119188
"""사주 계산 메인 함수"""
120189
year = birth_date.year
@@ -129,13 +198,21 @@ def calculate_four_pillars(self, birth_date: datetime) -> FourPillar:
129198
minute = None
130199

131200
pillars = self._calculate_kanshi(year, month, day, hour, minute)
201+
202+
# 오행 분석
203+
strong_elements, weak_elements = self._analyze_five_elements(pillars)
204+
205+
# FiveElements enum을 문자열로 변환
206+
strong_elements_str = [element.value for element in strong_elements] if strong_elements else None
207+
weak_elements_str = [element.value for element in weak_elements] if weak_elements else None
208+
132209
result: FourPillar = {
133210
"year_pillar": pillars[0], # 년주
134211
"month_pillar": pillars[1], # 월주
135212
"day_pillar": pillars[2], # 일주
213+
"time_pillar": pillars[3], # 시주
214+
"strong_elements": strong_elements_str,
215+
"weak_elements": weak_elements_str
136216
}
137217

138-
if pillars[3] is not None:
139-
result["time_pillar"] = pillars[3]
140-
141218
return result

src/four_pillars/entities/enums.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from enum import Enum
2+
3+
4+
class FiveElements(str, Enum):
5+
"""오행 (五行)"""
6+
WOOD = "목(木)" # 목
7+
FIRE = "화(火)" # 화
8+
EARTH = "토(土)" # 토
9+
METAL = "금(金)" # 금
10+
WATER = "수(水)" # 수
Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
from typing import Optional
2-
1+
from typing import Optional, List
32
from typing_extensions import TypedDict
43

54

6-
class FourPillar(TypedDict):
7-
year_pillar: str
8-
month_pillar: str
9-
day_pillar: str
10-
time_pillar: Optional[str] = None
5+
class FourPillar(TypedDict, total=False):
6+
year_pillar: str # 년주
7+
month_pillar: str # 월주
8+
day_pillar: str # 일주
9+
time_pillar: Optional[str] # 시주
10+
strong_elements: Optional[List[str]] # 가장 많은 오행
11+
weak_elements: Optional[List[str]] # 가장 적은 오행

src/hcx_client/common/parser.py

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,40 @@
11
import json
22
import re
33

4+
from src.common.logger import logger
5+
46

57
class Parser:
68
@staticmethod
7-
def parse_four_pillar(content: str):
8-
# 마크다운 JSON 블록에서 JSON 추출
9-
json_match = re.search(r"```json\s*(\{.*?\})\s*```", content, re.DOTALL)
10-
if json_match:
11-
try:
12-
json_str = json_match.group(1)
13-
json_data = json.loads(json_str)
14-
return json_data
15-
except json.JSONDecodeError as e:
16-
raise ValueError(f"JSON 파싱 오류: {e}")
17-
except Exception as e:
18-
raise ValueError(f"모델 검증 오류: {e}")
9+
def parse_json(content: str):
10+
try:
11+
# 마크다운 JSON 블록에서 JSON 추출
12+
json_match = re.search(r"```json\s*(\{.*?\})\s*```", content, re.DOTALL)
13+
if json_match:
14+
try:
15+
json_str = json_match.group(1)
16+
json_data = json.loads(json_str)
17+
return json_data
18+
except json.JSONDecodeError as e:
19+
raise ValueError(f"JSON 파싱 오류: {e}")
20+
except Exception as e:
21+
raise ValueError(f"모델 검증 오류: {e}")
22+
23+
# 일반 코드 블록에서 JSON 추출 (```json이 아닌 경우)
24+
code_match = re.search(r"```\s*(\{.*?\})\s*```", content, re.DOTALL)
25+
if code_match:
26+
try:
27+
json_str = code_match.group(1)
28+
json_data = json.loads(json_str)
29+
return json_data
30+
except json.JSONDecodeError as e:
31+
raise ValueError(f"JSON 파싱 오류: {e}")
32+
except Exception as e:
33+
raise ValueError(f"모델 검증 오류: {e}")
34+
35+
return json.loads(content)
1936

20-
# 일반 코드 블록에서 JSON 추출 (```json이 아닌 경우)
21-
code_match = re.search(r"```\s*(\{.*?\})\s*```", content, re.DOTALL)
22-
if code_match:
23-
try:
24-
json_str = code_match.group(1)
25-
json_data = json.loads(json_str)
26-
return json_data
27-
except json.JSONDecodeError as e:
28-
raise ValueError(f"JSON 파싱 오류: {e}")
29-
except Exception as e:
30-
raise ValueError(f"모델 검증 오류: {e}")
37+
except Exception as e:
38+
logger.info(f"[Parser] JSON 파싱 실패: {str(e)}")
39+
raise ValueError(f"마크다운 파싱 오류: {e}")
3140

32-
# JSON 블록을 찾지 못한 경우
33-
raise ValueError("마크다운 JSON 블록을 찾을 수 없습니다.")

src/hcx_client/prompts/fortune.yaml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,52 @@ four_pillar:
3232
성별: {gender}
3333
생년월일시: {birth_date}
3434
생년월일 기준: {birth_date_type}
35+
36+
lotto:
37+
system_prompt: |
38+
- 당신은 사주명리학 전문가이자 로또 번호 추천 시스템입니다.
39+
- 사용자의 로또 번호 6개를 추천합니다.
40+
- 추천 번호는 사용자의 사주명식과 추천 규칙을 고려하여 생성됩니다.
41+
- 사용자의 사주 명식은 년주, 월주, 일주, 시주로 구성되어 있으며, 각각의 천간과 지지를 포함합니다.
42+
- 추천 번호는 1부터 45까지의 숫자 중에서 선택됩니다.
43+
44+
## 추천 규칙
45+
- num1, num2, num3, num4, num5, num6은 추천된 번호입니다.
46+
- num1, num2는 사용자의 사주명식에서 가장 강한 기운을 가진 오행의 천간과 지지에 해당하는 숫자입니다.
47+
- num3, num4는 사용자의 재물운이 좋을 때 추천되는 숫자입니다.
48+
- num5, num6은 {frequent_nums} 번호 중 추천된 숫자입니다.
49+
50+
## 추가 조건
51+
num1, num2, num3, num4, num5, num6은 아래의 규칙을 **반드시 준수**해야 합니다:
52+
- 제외해야 하는 번호: {excluded_nums}
53+
- 일의 자리 숫자가 동일한 숫자를 **1쌍 이상** 포함해야 합니다.
54+
- 다만, 일의 자리 숫자가 3개 이상 겹치는 조합은 제외해야 합니다.
55+
- 홀수, 짝수를 각각 1개 이상 포함해야 합니다.
56+
- 번호 6개 중 3개 이상이 연속된 숫자인 조합은 제외해야 합니다.
57+
58+
## 응답 형식
59+
- 사용자의 오행 중 가장 강한 기운과 연관하여 추천 번호를 생성한 이유를 reason에 작성합니다.
60+
- reason은 한 줄로 작성하되, '(오행 중 가장 강한 기운) 기운이 강하여, 000 추천해요' 형식으로 작성합니다.
61+
- cold_nums에는 **1~3개**의 기피해야할 숫자를 **리스트 형식**으로 포함합니다. 반드시 1개 이상 포함해야 합니다.
62+
- cold_nums는 추천 번호와 겹치지 않도록 하되, 오행 중 가장 약한 기운에 해당하는 숫자를 포함합니다.
63+
64+
## 응답 예시
65+
### JSON으로 응답하되, 마크다운 코드 블록(```)을 절대 사용하지 않습니다.
66+
{{
67+
"reason": "화(火) 기운이 강하여, 역동적인 에너지를 가진 열정의 수를 추천해요.",
68+
"num1": 3,
69+
"num2": 34,
70+
"num3": 22,
71+
"num4": 28,
72+
"num5": 15,
73+
"num6": 45,
74+
"cold_nums": [4, 8, 16]
75+
}}
76+
77+
user_prompt: |
78+
- 년주: {year_pillar}
79+
- 월주: {month_pillar}
80+
- 일주: {day_pillar}
81+
- 시주: {time_pillar}
82+
- 오행 중 가장 강한 기운: {strong_element}
83+
- 오행 중 가장 약한 기운: {weak_element}

src/lotto/entities/models.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
from sqlalchemy import Column, Integer, Date, CheckConstraint, BigInteger
1+
from sqlalchemy import (
2+
Column,
3+
Integer,
4+
Date,
5+
CheckConstraint,
6+
BigInteger,
7+
String,
8+
DateTime,
9+
JSON,
10+
ForeignKey,
11+
)
12+
from sqlalchemy.orm import relationship
213

314
from src.config.database import Base
415

@@ -35,3 +46,16 @@ class LottoDraws(Base):
3546
CheckConstraint(f"{col} BETWEEN 1 AND 45", name=f"{col}_range_check")
3647
for col in ["num1", "num2", "num3", "num4", "num5", "num6", "bonus_num"]
3748
)
49+
50+
51+
class LottoRecommendations(Base):
52+
__tablename__ = "lotto_recommendations"
53+
54+
id = Column(Integer, primary_key=True, autoincrement=True)
55+
user_id = Column(String(255), ForeignKey("users.id"), nullable=False)
56+
round = Column(Integer, nullable=False)
57+
content = Column(JSON, nullable=False)
58+
created_at = Column(DateTime, nullable=False)
59+
updated_at = Column(DateTime, nullable=False)
60+
61+
user = relationship("User", back_populates="lotto_recommendations")

0 commit comments

Comments
 (0)