Skip to content

Commit 39a7934

Browse files
authored
배포 v1.6.0
배포 v1.6.0
2 parents 73eff08 + 5f1e945 commit 39a7934

File tree

3 files changed

+83
-45
lines changed

3 files changed

+83
-45
lines changed

.github/workflows/Deploy-ML-EC2.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ jobs:
2323
DEEPFM_TRAIN_MODEL_PATH: ${{ secrets.DEEPFM_TRAIN_MODEL_PATH }}
2424
DEEPFM_TRAIN_ENCODERS_PATH: ${{ secrets.DEEPFM_TRAIN_ENCODERS_PATH }}
2525
DEEPFM_TRAIN_KEY2INDEX_PATH: ${{ secrets.DEEPFM_TRAIN_KEY2INDEX_PATH }}
26+
VECTORDB_PATH: ${{ secrets.VECTORDB_PATH }}
2627
with:
2728
host: ${{ secrets.EC2_HOST_ML }}
2829
username: ${{ secrets.EC2_USER }}
@@ -52,6 +53,7 @@ jobs:
5253
echo "DEEPFM_TRAIN_MODEL_PATH=$DEEPFM_TRAIN_MODEL_PATH" >> .env
5354
echo "DEEPFM_TRAIN_ENCODERS_PATH=$DEEPFM_TRAIN_ENCODERS_PATH" >> .env
5455
echo "DEEPFM_TRAIN_KEY2INDEX_PATH=$DEEPFM_TRAIN_KEY2INDEX_PATH" >> .env
56+
echo "VECTORDB_PATH=$VECTORDB_PATH" >> .env
5557
5658
# Docker 컨테이너 재시작 (docker-compose가 있다면)
5759
if [ -f docker-compose.yml ]; then

MLOps/.env.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
OPENAI_API_KEY="YOUR_OPENAI_KEY"
2+
3+
# LOCAL PATH
4+
DEEPFM_TRAIN_MODEL_PATH="YOUR_PATH"
5+
DEEPFM_TRAIN_ENCODERS_PATH="YOUR_PATH"
6+
DEEPFM_TRAIN_KEY2INDEX_PATH="YOUR_PATH"
7+
8+
CLICK_LOG="YOUR_PATH"
9+
10+
VECTORDB_PATH="YOUR_PATH"
11+
12+
# DATABASE CONFIG
13+
DB_HOST="YOUR_HOST"
14+
DB_PORT=YOUR_PORT
15+
DB_USER="YOUR_USER"
16+
DB_PASSWORD="YOUR_PWD"
17+
DB_DATABASE="YOUR_DB"

MLOps/app/services/langchain_agent_service.py

Lines changed: 64 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import os
22
import json
33
import requests
4+
import re
45
from urllib.parse import quote
56
from typing import List, Dict, Any
67
import functools
8+
from dotenv import load_dotenv
79

810
from langchain.agents import AgentExecutor, create_openai_functions_agent
911
from langchain.callbacks import StdOutCallbackHandler
@@ -16,6 +18,8 @@
1618
from langchain.globals import set_llm_cache
1719
from langchain.cache import InMemoryCache
1820

21+
load_dotenv()
22+
1923
@functools.lru_cache(maxsize=100)
2024
def _elastic_search_cached(region: str, categories: tuple) -> list:
2125
"""Cached implementation for Elasticsearch search."""
@@ -44,13 +48,13 @@ def elastic_search(region: str, categories: List[str]) -> list:
4448
"""
4549
엘라스틱 검색 도구
4650
47-
사용자가 원하는 지역(region)과 카테고리(categories)를 입력받아,
48-
해당 조건에 부합하는 장소들의 UUID 리스트를 반환합니다.
49-
만약 정확한 카테고리가 입력되지 않더라도, 유사한 카테고리를 추출하여 검색에 활용합니다.
51+
사용자가 명시한 '지역(region)'과 '카테고리(categories)' 목록을 받아, 해당 조건에 부합하는 장소들의 UUID 리스트를 반환합니다.
52+
이 도구는 카테고리를 추론하지 않으므로, 반드시 명확한 카테고리 목록을 전달해야 합니다.
5053
5154
Args:
5255
region (str): 사용자가 명시한 지역명 (예: '강남', '홍대', '성수동', '이태원로', '잠실역')
53-
categories (list of str): 최소 1개, 최대 3개의 장소 카테고리 (예: '전시관', '기념관', '전문매장/상가', '5일장', '특산물판매점', '백화점', '상설시장', '문화전수시설', '문화원', '서양식', '건축/조형물', '음식점&카페', '박물관', '컨벤션센터', '역사관광지', '복합 레포츠', '공예/공방', '이색음식점', '영화관', '산업관광지', '중식', '문화시설', '쇼핑', '수상 레포츠', '관광지', '육상 레포츠', '학교', '관광자원', '스키(보드) 렌탈샵', '대형서점', '휴양관광지', '외국문화원', '자연관광지', '레포츠', '한식', '일식', '도서관', '체험관광지', '카페/전통찻집', '면세점', '공연장', '미술관/화랑')
56+
categories (list of str): 검색할 장소 카테고리 목록입니다. (예: ['한식', '카페/전통찻집']).
57+
사용 가능한 전체 카테고리: '전시관', '기념관', '전문매장/상가', '5일장', '특산물판매점', '백화점', '상설시장', '문화전수시설', '문화원', '서양식', '건축/조형물', '음식점&카페', '박물관', '컨벤션센터', '역사관광지', '복합 레포츠', '공예/공방', '이색음식점', '영화관', '산업관광지', '중식', '문화시설', '쇼핑', '수상 레포츠', '관광지', '육상 레포츠', '학교', '관광자원', '스키(보드) 렌탈샵', '대형서점', '휴양관광지', '외국문화원', '자연관광지', '레포츠', '한식', '일식', '도서관', '체험관광지', '카페/전통찻집', '면세점', '공연장', '미술관/화랑'
5458
5559
Returns:
5660
list: 조건에 부합하는 장소의 UUID 리스트 (예: ['uuid1', 'uuid2', ...])
@@ -59,7 +63,6 @@ def elastic_search(region: str, categories: List[str]) -> list:
5963
>>> elastic_search('종로', ['음식점&카페', '문화시설'])
6064
['a1b2c3', 'd4e5f6']
6165
"""
62-
# Convert list to a sorted tuple to make it hashable and canonical for caching
6366
return _elastic_search_cached(region, tuple(sorted(categories)))
6467

6568
class LangchainAgentService:
@@ -78,8 +81,8 @@ def __init__(self, openai_api_key: str):
7881
)
7982

8083
# ChromaDB 로드
81-
chroma_db_path = "/app/data/chroma_db_bge"
82-
if not os.path.exists(chroma_db_path):
84+
chroma_db_path = os.getenv("VECTORDB_PATH")
85+
if not chroma_db_path or not os.path.exists(chroma_db_path):
8386
raise FileNotFoundError(f"ChromaDB 경로를 찾을 수 없습니다: {chroma_db_path}")
8487
self.chroma_bge = Chroma(
8588
persist_directory=chroma_db_path,
@@ -140,42 +143,52 @@ def search_with_filtering(query: str, place_uuids: List[str]) -> List[Dict[str,
140143

141144
def _create_prompt_template(self):
142145
template = """
143-
당신은 서울 코스 추천 챗봇입니다.
144-
145-
아래의 도구들을 반드시 순서대로 사용해 사용자의 맞춤 서울 코스를 추천하세요.
146-
147-
[도구 사용 순서]
148-
1. elastic_search: 사용자의 질문에서 '지역'과 '카테고리' 키워드를 추출해 elastic_search 도구를 사용하세요.
149-
2. search_with_filtering: 사용자의 쿼리, elastic_search에서 반환한 장소의 uuid 리스트를 모두 사용해 search_with_filtering 도구를 호출하세요.
150-
151-
- 각 도구의 입력값은 이전 단계의 출력값을 반드시 활용하세요.
152-
- 도구를 순서대로 모두 사용한 후에만 최종 코스를 추천할 수 있습니다.
153-
- 장소 데이터에 없는 장소는 상상하지 말고, 반드시 도구 결과만 사용하세요.
154-
- 따뜻하고 친근한 말투로 대화하세요.
155-
156-
[코스 생성 규칙]
157-
- `search_with_filtering` 도구에서 반환된 장소 목록을 분석해야 합니다.
158-
- 사용자의 요청에 언급된 각 활동(예: 카페, 한식, 박물관)에 대해 가장 적합한 장소를 **하나씩만** 선택하세요.
159-
- 사용자가 언급한 순서대로 코스를 구성해야 합니다. 예를 들어, "카페 갔다가 밥 먹고 싶어"라고 했다면, 추천된 장소 목록에서 카페 하나와 음식점 하나를 선택하여 "1. OO카페\\n2. XX식당" 순서로 코스를 만들어야 합니다.
160-
- 최종적으로 구성된 코스에 포함된 장소들의 UUID만 `placeid` 리스트에 담아주세요.
161-
- 생성된 코스는 `str` 필드에 친절한 소개 문구와 함께 담아주세요.
162-
163-
[출력 포맷]
164-
- 모든 답변은 아래 JSON 형식을 반드시 따르세요.
165-
- `placeid`는 줄바꿈을 사용하지 마세요.
166-
- `str`은 줄바꿈과 단락을 구분하세요. `\n`을 통해서 줄바꿈을 표시하고, `\n<br>`을 통해서 단락이 바뀜을 표시하세요.
167-
- `<n>`과 `<br>`은 줄바꿈과 단락을 구분하는 문자입니다. 이외의 문자는 사용하지 마세요.
168-
- 주소와 장소 사이에는 반드시 줄바꿈으로 구분하세요.
169-
- 다음 장소의 설명으로 넘어갈 때, 단락을 구분하세요.
146+
당신은 서울 여행 코스 추천 챗봇입니다. 당신의 유일하고 가장 중요한 임무는 사용자와의 대화 후, 아래 [JSON 출력 형식]에 맞는 유효한 JSON 객체만을 출력하는 것입니다. **어떤 경우에도 JSON 형식이 아닌 텍스트를 출력해서는 안 됩니다.**
147+
148+
**중요한 제약사항**: 장소에 대한 모든 정보는 반드시 제공된 도구(elastic_search, search_with_filtering)를 통해 검색된 데이터베이스 결과만을 사용해야 합니다. 당신의 사전 지식으로 장소 정보를 추가하거나 보완해서는 안 됩니다.
149+
150+
[JSON 출력 형식]
151+
- 모든 응답은 반드시 아래 형식의 JSON 객체여야 합니다.
152+
```json
170153
{{
171-
"placeid": ["uuid1", ...],
172-
"str": "추천 코스에 대한 친근한 설명과 함께 장소 목록을 제공합니다. 예: 요청하신 종로 데이트 코스입니다.<br>1. 장소명 - 주소<n>설명<br>2. 장소명 - 주소<n>설명"
154+
"placeid": ["장소 UUID 목록"],
155+
"str": "사용자에게 보여줄 메시지"
173156
}}
174-
175-
[주의]
176-
- 반드시 elastic_search → search_with_filtering 순서로 도구를 호출하세요.
177-
- 도구 호출 없이 임의로 장소를 추천하지 마세요.
178-
- 최종 응답은 JSON 형식만 사용하고, 다른 텍스트는 추가하지 마세요.
157+
```
158+
159+
[응답 생성 규칙]
160+
- 당신은 아래의 규칙에 따라 'str' 필드에 들어갈 메시지를 결정하고, 그 외의 모든 경우에는 추천 장소 목록을 생성합니다.
161+
- **1. 정보 부족 시 질문**:
162+
* 대화 전체에서 **'지역'** 정보가 확인되지 않으면, `str` 필드에 어느 지역을 원하는지 5개 이상의 예시를 포함해 물어보는 문구를 담아 JSON을 출력합니다.
163+
* '지역'은 있으나 **'할 일'** 정보가 부족하거나 모호하다면, `str` 필드에 어떤 활동을 원하는지 5개 이상의 예시를 포함해 물어보는 문구를 담아 JSON을 출력합니다.
164+
* 이 경우, `placeid`는 항상 빈 리스트 `[]`입니다.
165+
- **2. 검색 실패 시 대안 제시**:
166+
* 도구 검색 결과, 요청한 '지역'에 맞는 장소가 없다면 `str` 필드에 "아쉽게도 요청하신 지역에는 맞는 장소가 없네요. 혹시 강남이나 홍대 같은 다른 인기 지역은 어떠세요?"와 같이 구체적인 대안을 제시하는 질문을 담아 JSON을 출력합니다.
167+
* 이 경우, `placeid`는 항상 빈 리스트 `[]`입니다.
168+
- **3. 성공 시 코스 추천**:
169+
* 도구 검색에 성공하면, `placeid`에는 추천 장소들의 UUID를, `str` 필드에는 [str 필드 작성 규칙]에 따라 생성된 추천 코스 설명을 담아 JSON을 출력합니다.
170+
* 사용자가 할 일에 대해서, 각각 하나의 장소만을 추천해서 코스를 구성합니다.
171+
* 반드시 `placeid` 배열에 포함된 `UUID`와 `str` 필드에서 설명하는 장소가 정확히 일치해야 합니다.
172+
* 예를 들어서, 사용자가 카페를 원하면 카페 카테고리에서 하나를 추천하고, 전시관을 원하면 전시관 카테고리에서 하나를 추천합니다.
173+
- **4. 코스 수정 요청**:
174+
* 사용자가 코스 수정을 요청하면 장소 후보를 몇 군데 제시한 후 고르도록 하세요.
175+
* 이 경우, `placeid`는 항상 빈 리스트 `[]`입니다.
176+
177+
[도구 사용 규칙]
178+
- **'지역'과 '할 일'이 대화 전체를 통해 명확하게 확정되었을 때만 도구를 사용합니다.**
179+
- 사용자가 이전에 말한 '할 일'을 기억했다가, 새로운 지역을 말하면 해당 지역에서 이전에 원했던 '할 일'을 찾아야 합니다.
180+
181+
[str 필드 작성 규칙 - 중요: RAG 데이터만 사용]
182+
- **절대적 제약사항**: 장소에 대한 모든 정보(이름, 주소, 설명 등)는 반드시 도구 검색을 통해 얻은 데이터베이스 정보만을 사용해야 합니다. 당신의 사전 지식이나 추측으로 장소 정보를 보완하거나 추가해서는 안 됩니다.
183+
- 검색된 데이터에 없는 정보는 절대 언급하지 마세요.
184+
- 항상 부드럽고 친근한 말투로 작성합니다.
185+
- 코스 추천의 시작은 즐거운 소개 문구로 시작합니다.
186+
- 각 장소 정보는 반드시 search_with_filtering 도구로 검색된 결과에서만 가져와서 이름 - 주소 - 설명 형식으로 작성합니다.
187+
- 장소는 항상 번호로 구분하세요. 이 순서는 사용자가 원하는 순서를 따라야 합니다. (예시: 1. 상호명 - 주소<n>장소에 대한 설명)
188+
- 줄 바꿈은 `<n>`으로 표시하고, 단락 구분은 반드시 `<br>` 하나만 사용합니다.
189+
- **중요**: `<br>` 태그는 절대로 연속으로 사용하지 마세요. `<br><br>`가 아닌 `<br>` 하나만 사용해야 합니다.
190+
- 주소와 설명 사이는 `<n>`으로 구분하고, 장소와 장소 사이는 반드시 `<br>` 하나로만 구분합니다.
191+
- `<n>`과 `<br>` 외의 마크다운은 사용하지 않습니다.
179192
"""
180193
return ChatPromptTemplate.from_messages([
181194
("system", template),
@@ -205,19 +218,25 @@ def get_response(self, user_message: str, user_id: str) -> dict:
205218
)
206219

207220
try:
208-
# `user_id`를 `user_uuid`로 전달하여 에이전트 호출
209221
result = agent_executor.invoke(
210222
{"input": user_message},
211223
config={"callbacks": [StdOutCallbackHandler()]}
212224
)
213225

214226
output_str = result.get("output", "{}")
227+
215228
try:
216-
# 에이전트의 출력이 JSON 문자열일 경우 파싱
217-
response_data = json.loads(output_str)
229+
# 정규식을 사용하여 응답에서 JSON 객체만 추출
230+
match = re.search(r'\{.*\}', output_str, re.DOTALL)
231+
if match:
232+
json_str = match.group(0)
233+
response_data = json.loads(json_str)
234+
else:
235+
# JSON 객체를 찾지 못한 경우, 전체 문자열을 응답으로 처리
236+
response_data = {"placeid": [], "str": "죄송합니다. 처리 중 오류가 발생했습니다."}
218237
except (json.JSONDecodeError, TypeError):
219238
# JSON 파싱 실패 시, 추천 코스를 담은 텍스트 응답으로 처리
220-
response_data = {"placeid": [], "str": output_str}
239+
response_data = {"placeid": [], "str": "죄송합니다. 처리 중 오류가 발생했습니다."}
221240

222241
return response_data
223242

0 commit comments

Comments
 (0)