11import os
22import json
33import requests
4+ import re
45from urllib .parse import quote
56from typing import List , Dict , Any
67import functools
8+ from dotenv import load_dotenv
79
810from langchain .agents import AgentExecutor , create_openai_functions_agent
911from langchain .callbacks import StdOutCallbackHandler
1618from langchain .globals import set_llm_cache
1719from langchain .cache import InMemoryCache
1820
21+ load_dotenv ()
22+
1923@functools .lru_cache (maxsize = 100 )
2024def _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
6568class 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