Skip to content

08 실습 - 자연어 상품 검색 및 응답 챗봇 (2)

해당 프로그램을 만들기 위해서 spec를 docs/spec-shop-assistant.md 에 다음과 같이 작성합니다.

# 쇼핑몰 어시스턴트 챗봇 개발
## 1. 개요
- 해당 프로그램은 쇼핑몰 어시스턴트 챗봇 프로그램이다.
- 사용자가 챗봇에 상품과 애터미의 마케팅 플랜에 대해서 자연어로 질문을 하면 해당 질문에 대해서 자연어로 답변을 합니다.
## 2. 기능 구현
- 상품 검색 Flow: 사용자 질의->의도파악 및 검색어 추출 -> 상품 검색인 경우 RAG 벡터 검색과 데이터를 추출 -> LLM에 해당 데이터 전달 및 답변 생성 -> 답변 출력
- 상품 검색 결과를 LLM에 마지막 질의할때 사용자의 원 질문내용도 함께 전달이 반드시 되어야 합니다.
- 마케팅 플랜 검색 Flow : 사용자 질의->의도파악 및 검색어 추출 -> 마케팅 플랜 검색인 경우 해당 마케팅 플렌 정보를 바탕으로 답변 생성 -> 답변 출력
- 기타 답변 처리 Flow : 사용자 질의->의도파악 및 검색어 추출 에서 기타 처리하지 못하는 답변 생성 응답 -> 답변 출력
## 3. 프롬프트
- 질문의 의도 파악은 prompt/query-process.md에 정의된 시스템 프롬프트를 사용한다.
- 상품 검색의 결과 처리 프롬프트는 prompt/product-search.md에 정의된 시스템 프롬프트를 사용한다.
- 마케팅 플랜의 결과 처리 프롬프트는 prompt/marketing-plan.md에 정의된 시스템 프롬프트를 사용한다.
## 4. 의도 파악 및 검색어 추출 응답 포멧
- Output Format (JSON Only)
아래 JSON 스키마를 준수합니다.
{
"category": "product | marketing | general",
"search_query": "핵심 명사 키워드들의 조합 (띄어쓰기 구분)",
"general_msg": "카테고리가 general일때 정중한 답변 거절 메시지, 없는경우 none"
}
- 응답처리시에 category를 가지고 분기처리합니다.
- **[중요]** LLM이 마크다운이나 일반 텍스트를 섞어 반환하여 발생하는 파싱 에러(JSONDecodeError)를 방지하기 위해, Gemini API 호출 시 `GenerateContentConfig` 내에 반드시 `response_mime_type="application/json"` 옵션을 추가해야 합니다.
## 5. 구현
- gradio를 이용해서 UI를 만듭니다. 챗봇의 기능처럼 질의 및 응답을 처리하도록 하고 응답은 markdown 형식의 출력을 표시하도록 합니다.(Markdown 형식 출력 렌더링 지원)
- gemini 모델은 다음을 사용합니다. **gemini-3.1-flash-lite-preview** 최신 모델이므로 **반드시** 해당 모델만 사용합니다. 다른 모델로 바꾸지 않습니다. (https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-lite-preview?hl=ko 참고)
- gemini sdk는 최신 SDK인 genai 를 사용해야 합니다.
- gemini의 기존 문맥 대화를 기억하기 위해서 최신 질의/응답 5개를 전달합니다.
- 상품검색시 vector 검색은 기존에 만들어진 chromaDB를 사용을 합니다. /chroma/ 디렉토리에 있으며 해당 vector 검색 코드는 vector-search-product.py를 참고하여 개발합니다.
- 상품검색시 vector 검색 결과를 넘겨줄때 해당 백터의 meta테그와 함께 벡터검색의 document(원 text)를 LLM에 같이 전달합니다. 이때 상품은 Top N 5개 정보를 전달합니다.
- 마케팅 플랜 검색시에는 해당 프롬프트와 함께 검색어를 같이 전달합니다.
- Gemini API Key는 .env 파일에서 로드합니다.(GEMINI_API_KEY 로 변수 사용) python-dotenv 패키지를 사용합니다.
- 채팅 창은 한줄로 넣고 엔터를 치면 응답이 되게 해주세요.(shift+enter는 여러줄, enter는 그냥 실행)
- context7 MCP로 최신 API에서 반드시 확인해서 구현해줘
## 6. 실행 파일
- 작업 파일은 shop-assistant.py로 작성합니다.
- 디버깅 모드시 python 코드 수정시 즉각 반영되도록 수정해주세요.
  • 프로그램 개발전에 이전에 gemini를 사용하기 위해 key를 설정해야 합니다. “.env” 파일을 만들고 해당 키값을(별도 공유된) 넣습니다.
GEMINI_API_KEY={API 키 값}
  • 해당 Spec이 이미 자세한 내용을 포함하고 있기 때문에 별도의 Spec 생성 기능을 하지 않고 바로 프로그램 생성을 합니다. command 에서 해당 파일을 넣고 프로그램을 만들다라고 요청합니다.(예: docs/spec-shop-assistant.md 를 보고 해당 프로그램을 만들어주세요. 실행시 에러가 나면 모두 수정해주세요. 필요시 브라우져를 띄워서 디버깅 해주세요)

다음처럼 프로그램을 실행하면 해당 접속 주소와 port가 나타납니다. 에러가 없으면 브라우져로 해당 주소로 접속을 합니다.

Terminal window
cmd> python shop-assistant.py
✅ 프롬프트 파일 로드 완료
✅ Gemini 클라이언트 초기화 완료 (모델: gemini-3.1-flash-lite-preview)
[Alibaba-NLP/gte-multilingual-base] 임베딩 모델을 로드합니다...
최초 실행 시 모델 다운로드로 인해 수 분이 소요될 수 있습니다.
Some weights of the model checkpoint at Alibaba-NLP/gte-multilingual-base were not used when initializing NewModel: ['classifier.bias', 'classifier.weight']
- This IS expected if you are initializing NewModel from the checkpoint of a model trained on another task or with another architecture (e.g. ini another architecture (e.g. initializing a BertForSequenceClassification model from a BertForPreTraining model).
- This IS NOT expected if you are initializing NewModel from the checkpoint of a model that you expect to be exactly identical (initializing a BertForSequenceClassification model from a BertForSequenceClassification model).
✅ 임베딩 모델 로드 완료!
⏳ ChromaDB 컬렉션을 로드합니다... (./chroma)
✅ ChromaDB 로드 완료 (총 606개 상품)
* Running on local URL: http://127.0.0.1:7860
* To create a public link, set `share=True` in `launch()`.

  • 해당 설계한 대로 동작을 하는지 실습합니다.
    • 면역력에 좋은 제품이 뭐야?
    • Can I find a product about sun cram? (영어 질문 / 영어 답변)
    • 세일즈 마스터가 되려면 어떻게 해야되?
    • 첫 수당은 어떻게 받을수 있어?
    • 40대 여성에게 추천할 만한 화장품을 알려줘
  • 프롬프트를 자신의 스타일에 맞게 변형해보세요
    • 톤 앤 매너
      • 친구 톤으로, 아나운서 톤으로, 경상도 사투리톤으로, 교육자 톤으로.. 등등
    • 자유로운 대답
      • 내부에 알고 있는 정보와 더불어 대답을 해줘(기존 환각 무시)
  • 기존 맥락을 알고 있는지(최대 5개) 확인해보세요.
    • 이전 알려준 제품과 다른 제품도 알려줘
  • 프롬프트 변경시에 프로그램 재시작을 해야합니다.(Ctrl+C / 재시작)
  • 토큰 이슈로 소스 생성이 안되는 경우 아래 코드로 실습(shop-assistant.py)
# ============================================================
# 애터미 쇼핑몰 어시스턴트 챗봇 (Shop Assistant)
#
# 필요 패키지 설치:
# pip install google-genai chromadb sentence-transformers gradio numpy python-dotenv
#
# 설명:
# - 사용자 질의 -> 의도 파악 (query-process.md 프롬프트) -> 카테고리 분기
# - [product] : ChromaDB 벡터 검색 (Top 5) -> LLM (product-recommand.md 프롬프트)
# - [marketing]: marketing-plan.md 전체 컨텍스트 -> LLM 답변 생성
# - [general] : query-process 응답의 general_msg 반환
# - Gemini SDK: google-genai (최신 SDK)
# - LLM 모델 : gemini-3.1-flash-light-preview
# - 대화 히스토리: 최신 5턴(질의+응답 쌍) 유지
# ============================================================
import os
import json
import re
import chromadb
import gradio as gr
from pathlib import Path
from dotenv import load_dotenv
# .env 파일에서 환경 변수 로드 (GEMINI_API_KEY 등)
load_dotenv()
from sentence_transformers import SentenceTransformer
from google import genai
from google.genai import types
# ============================================================
# 1. 설정 상수
# ============================================================
GEMINI_MODEL = "gemini-3.1-flash-lite-preview" # 실제 사용 가능한 최신 flash 모델
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "")
CHROMA_DIR = "./chroma"
COLLECTION_NAME = "products"
EMBED_MODEL_ID = "Alibaba-NLP/gte-multilingual-base"
MAX_HISTORY_TURN = 5 # 유지할 최근 대화 턴 수
TOP_N_PRODUCTS = 5 # 벡터 검색 상위 N개
# 프롬프트 파일 경로
PROMPT_DIR = Path("./prompt")
QUERY_PROCESS_PROMPT_FILE = PROMPT_DIR / "query-process.md"
PRODUCT_PROMPT_FILE = PROMPT_DIR / "product-recommand.md"
MARKETING_PROMPT_FILE = PROMPT_DIR / "marketing-plan.md"
# ============================================================
# 2. 프롬프트 파일 로드
# ============================================================
def load_prompt(path: Path) -> str:
if path.exists():
return path.read_text(encoding="utf-8")
print(f"⚠️ 프롬프트 파일을 찾을 수 없습니다: {path}")
return ""
QUERY_PROCESS_PROMPT = load_prompt(QUERY_PROCESS_PROMPT_FILE)
PRODUCT_PROMPT = load_prompt(PRODUCT_PROMPT_FILE)
MARKETING_PROMPT = load_prompt(MARKETING_PROMPT_FILE)
print("✅ 프롬프트 파일 로드 완료")
# ============================================================
# 3. Gemini 클라이언트 초기화
# ============================================================
if not GEMINI_API_KEY:
print("⚠️ GEMINI_API_KEY 환경 변수가 설정되지 않았습니다. 실행 전 설정이 필요합니다.")
gemini_client = genai.Client(api_key=GEMINI_API_KEY)
print(f"✅ Gemini 클라이언트 초기화 완료 (모델: {GEMINI_MODEL})")
# ============================================================
# 4. 임베딩 모델 로드
# ============================================================
print(f"[{EMBED_MODEL_ID}] 임베딩 모델을 로드합니다...")
print("최초 실행 시 모델 다운로드로 인해 수 분이 소요될 수 있습니다.")
embed_model = SentenceTransformer(EMBED_MODEL_ID, trust_remote_code=True)
print("✅ 임베딩 모델 로드 완료!\n")
# ============================================================
# 5. ChromaDB 연결 (기존 벡터 DB 사용)
# ============================================================
print(f"⏳ ChromaDB 컬렉션을 로드합니다... ({CHROMA_DIR})")
chroma_client = chromadb.PersistentClient(path=CHROMA_DIR)
collection = chroma_client.get_or_create_collection(
name=COLLECTION_NAME,
metadata={"hnsw:space": "cosine"}
)
print(f"✅ ChromaDB 로드 완료 (총 {collection.count()}개 상품)\n")
# ============================================================
# 6. 벡터 검색 함수
# ============================================================
def vector_search_products(query: str, top_n: int = TOP_N_PRODUCTS) -> list[dict]:
"""
ChromaDB에서 query를 벡터 임베딩 후 코사인 유사도 기반 검색 수행.
반환: [{meta: {...}, document: str, score: float}, ...]
"""
if not query.strip():
return []
query_vec = embed_model.encode([query], show_progress_bar=False).tolist()
results = collection.query(
query_embeddings=query_vec,
n_results=top_n,
include=["documents", "metadatas", "distances"]
)
if not results["ids"] or not results["ids"][0]:
return []
items = []
for i in range(len(results["ids"][0])):
score = max(0.0, 1.0 - results["distances"][0][i])
items.append({
"meta": results["metadatas"][0][i],
"document": results["documents"][0][i],
"score": score
})
return items
# ============================================================
# 7. 히스토리 -> Gemini contents 변환 헬퍼
# ============================================================
def build_history_contents(history: list) -> list[types.Content]:
"""
Gradio 6 히스토리 [{'role': 'user'|'assistant', 'content': str}, ...]
-> Gemini SDK types.Content 리스트로 변환 (최근 MAX_HISTORY_TURN 턴만)
user/assistant 메시지 쌍이 각 1턴이므로 최근 MAX_HISTORY_TURN*2 항목을 사용
"""
recent = history[-(MAX_HISTORY_TURN * 2):] if len(history) > MAX_HISTORY_TURN * 2 else history
contents = []
for msg in recent:
role = msg.get("role", "")
content = msg.get("content", "")
if not content:
continue
gemini_role = "model" if role == "assistant" else "user"
contents.append(types.Content(
role=gemini_role,
parts=[types.Part(text=str(content))]
))
return contents
# ============================================================
# 8. 의도 파악 함수 (1단계 LLM 호출)
# ============================================================
def classify_intent(user_query: str, history: list) -> dict:
"""
query-process.md 프롬프트를 사용하여 사용자 질문의 의도(카테고리)를 파악하고
최적화된 검색 쿼리를 추출합니다.
반환: {"category": str, "search_query": str, "general_msg": str}
"""
history_text = ""
recent = history[-(MAX_HISTORY_TURN * 2):] if len(history) > MAX_HISTORY_TURN * 2 else history
if recent:
lines = []
for msg in recent:
role = msg.get("role", "")
content = msg.get("content", "")
if role == "user" and content:
lines.append(f"User: {content}")
elif role == "assistant" and content:
lines.append(f"Assistant: {content}")
history_text = "\n".join(lines)
user_content = f"conversation_history:\n{history_text}\n\ncurrent_query: {user_query}" if history_text else f"current_query: {user_query}"
try:
response = gemini_client.models.generate_content(
model=GEMINI_MODEL,
config=types.GenerateContentConfig(
system_instruction=QUERY_PROCESS_PROMPT,
temperature=0.1,
response_mime_type="application/json",
),
contents=user_content
)
raw_text = response.text.strip()
# JSON 추출 (마크다운 코드블록이 있으면 제거)
cleaned = re.sub(r"```json|```", "", raw_text).strip()
return json.loads(cleaned)
except json.JSONDecodeError as e:
print(f"⚠️ 의도 파악 JSON 파싱 오류: {e}\n원문: {raw_text}")
return {"category": "general", "search_query": user_query, "general_msg": "⚠️ 의도 파악 중 오류가 발생했습니다."}
except Exception as e:
print(f"⚠️ 의도 파악 LLM 호출 오류: {e}")
return {"category": "general", "search_query": user_query, "general_msg": f"⚠️ 오류가 발생했습니다: {e}"}
# ============================================================
# 9. 상품 검색 답변 생성 (2단계 LLM 호출 - product)
# ============================================================
def generate_product_answer(user_query: str, search_query: str, history: list) -> str:
"""
ChromaDB에서 검색된 상품 데이터를 바탕으로 LLM 답변을 생성합니다.
"""
products = vector_search_products(search_query)
if not products:
return "죄송합니다, 해당 질문과 관련된 상품 정보를 찾지 못했습니다. 다른 키워드로 다시 검색해 주세요. 🙏"
# 검색 결과를 LLM에 전달할 컨텍스트 문자열로 포맷팅
product_context_parts = []
for i, item in enumerate(products, 1):
meta = item["meta"]
doc = item["document"]
score = item["score"]
context = f"""
--- 상품 {i} (유사도: {score:.4f}) ---
상품코드(PRODUCT_ID): {meta.get('PRODUCT_ID', 'N/A')}
상품명(PRODUCT_NAME): {meta.get('PRODUCT_NAME', 'N/A')}
카테고리(CATEGORIES): {meta.get('CATEGORIES', 'N/A')}
가격(PRICE): {int(meta.get('PRICE', 0)):,}
PV(PV_PRICE): {int(meta.get('PV_PRICE', 0)):,} PV
태그(TAGS): {meta.get('TAGS', 'N/A')}
이미지(Image): {meta.get('Image', '')}
요약(SUMMARY): {meta.get('SUMMARY', 'N/A')}
상세설명(DESC_MD):
{doc}
""".strip()
product_context_parts.append(context)
product_context = "\n\n".join(product_context_parts)
# LLM에 보낼 최종 사용자 메시지
user_message_for_llm = f"""
[검색된 상품 데이터]
{product_context}
[사용자 질문]
{user_query}
""".strip()
# 히스토리 기반 대화 컨텍스트 구성
history_contents = build_history_contents(history)
history_contents.append(types.Content(
role="user",
parts=[types.Part(text=user_message_for_llm)]
))
try:
response = gemini_client.models.generate_content(
model=GEMINI_MODEL,
config=types.GenerateContentConfig(
system_instruction=PRODUCT_PROMPT,
temperature=0.7,
),
contents=history_contents
)
return response.text
except Exception as e:
print(f"⚠️ 상품 답변 생성 오류: {e}")
return f"⚠️ 답변 생성 중 오류가 발생했습니다: {e}"
# ============================================================
# 10. 마케팅 플랜 답변 생성 (2단계 LLM 호출 - marketing)
# ============================================================
def generate_marketing_answer(user_query: str, search_query: str, history: list) -> str:
"""
marketing-plan.md 컨텍스트를 시스템 프롬프트로 사용하여 LLM 답변을 생성합니다.
"""
user_message_for_llm = f"검색어: {search_query}\n\n질문: {user_query}"
history_contents = build_history_contents(history)
history_contents.append(types.Content(
role="user",
parts=[types.Part(text=user_message_for_llm)]
))
try:
response = gemini_client.models.generate_content(
model=GEMINI_MODEL,
config=types.GenerateContentConfig(
system_instruction=MARKETING_PROMPT,
temperature=0.5,
),
contents=history_contents
)
return response.text
except Exception as e:
print(f"⚠️ 마케팅 플랜 답변 생성 오류: {e}")
return f"⚠️ 답변 생성 중 오류가 발생했습니다: {e}"
# ============================================================
# 11. 챗봇 메인 핸들러
# ============================================================
def chat(user_message: str, history: list):
"""
Gradio 6 ChatInterface 핸들러.
history: [{'role': 'user'|'assistant', 'content': str}, ...]
"""
if not user_message.strip():
yield history, history
return
if not GEMINI_API_KEY:
error_msg = "⚠️ **GEMINI_API_KEY** 환경 변수가 설정되지 않았습니다. 환경 변수를 설정한 후 다시 시작해 주세요."
updated = history + [
{"role": "user", "content": user_message},
{"role": "assistant", "content": error_msg}
]
yield updated, updated
return
# ── 1단계: 의도 파악 ────────────────────────────────────────
print(f"\n[사용자 입력] {user_message}")
intent = classify_intent(user_message, history)
category = intent.get("category", "general")
search_query = intent.get("search_query", user_message)
general_msg = intent.get("general_msg", "none")
print(f"[의도 분류] category={category}, search_query={search_query}")
# ── 2단계: 카테고리별 분기 처리 ────────────────────────────
if category == "product":
answer = generate_product_answer(user_message, search_query, history)
elif category == "marketing":
answer = generate_marketing_answer(user_message, search_query, history)
else:
if general_msg and general_msg.lower() != "none":
answer = general_msg
else:
answer = "안녕하세요! 😊 저는 **애터미 쇼핑몰 어시스턴트**입니다.\n\n상품 추천이나 **마케팅 플랜**에 대해 궁금한 점을 자유롭게 물어봐 주세요!"
updated = history + [
{"role": "user", "content": user_message},
{"role": "assistant", "content": answer}
]
yield updated, updated
# ============================================================
# 12. Gradio UI
# ============================================================
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;800;900&display=swap');
body, .gradio-container {
font-family: 'Noto Sans KR', sans-serif !important;
background: #f0f4f8 !important;
}
/* ── 헤더 ── */
#app-header {
background: linear-gradient(135deg, #0f2957 0%, #1d4ed8 55%, #3b82f6 100%);
border-radius: 18px;
padding: 30px 40px;
margin-bottom: 16px;
text-align: center;
box-shadow: 0 6px 30px rgba(29,78,216,0.35);
position: relative;
overflow: hidden;
}
#app-header::before {
content: '';
position: absolute;
top: -40%; left: -10%;
width: 60%; height: 200%;
background: rgba(255,255,255,0.05);
transform: rotate(-20deg);
border-radius: 50%;
}
#app-header h1 {
color: #ffffff !important;
font-size: 2rem;
font-weight: 900;
margin: 0 0 8px 0;
letter-spacing: -0.5px;
}
#app-header p {
color: #bfdbfe !important;
font-size: 0.92rem;
margin: 0;
}
#app-header .badge-row {
margin-top: 12px;
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
#app-header .badge {
display: inline-block;
background: rgba(255,255,255,0.18);
border: 1px solid rgba(255,255,255,0.4);
color: #e0f2fe !important;
border-radius: 20px;
padding: 3px 14px;
font-size: 0.78rem;
font-weight: 600;
}
/* ── 챗봇 컨테이너 ── */
.chat-container {
background: #ffffff;
border: 1px solid #dbeafe;
border-radius: 16px;
padding: 0;
box-shadow: 0 2px 16px rgba(37,99,235,0.08);
overflow: hidden;
}
/* ── Chatbot 메시지 스타일 ── */
.chatbot-box {
background: #f8faff !important;
}
.chatbot-box .message.user {
background: linear-gradient(135deg, #1d4ed8, #3b82f6) !important;
color: #ffffff !important;
border-radius: 18px 18px 4px 18px !important;
box-shadow: 0 2px 8px rgba(29,78,216,0.25) !important;
}
.chatbot-box .message.bot {
background: #ffffff !important;
border: 1px solid #e2e8f0 !important;
border-radius: 18px 18px 18px 4px !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.06) !important;
color: #111827 !important;
}
/* 봇 메시지 내부 텍스트 색상 */
.chatbot-box .message.bot *,
.chatbot-box .message.bot p,
.chatbot-box .message.bot span {
color: #111827 !important;
}
/* 마크다운 이미지 */
.chatbot-box .message.bot img {
max-width: 180px;
border-radius: 10px;
margin: 6px 0;
box-shadow: 0 2px 10px rgba(0,0,0,0.12);
}
/* 마크다운 테이블 */
.chatbot-box .message.bot table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
font-size: 0.88rem;
}
.chatbot-box .message.bot th {
background: #dbeafe;
color: #1e3a8a !important;
padding: 8px 12px;
border-bottom: 2px solid #93c5fd;
font-weight: 700;
}
.chatbot-box .message.bot td {
padding: 7px 12px;
border-bottom: 1px solid #e2e8f0;
vertical-align: top;
color: #111827 !important;
}
.chatbot-box .message.bot tr:hover td {
background: #eff6ff;
}
/* ── 입력 영역 ── */
#chat-input-area {
background: #ffffff;
border-top: 1px solid #e2e8f0;
padding: 12px 16px;
border-radius: 0 0 16px 16px;
}
#chat-input textarea {
border: 1.5px solid #93c5fd !important;
border-radius: 12px !important;
padding: 12px 16px !important;
font-size: 0.95rem !important;
color: #111827 !important;
resize: none;
transition: border-color 0.2s;
}
#chat-input textarea:focus {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 3px rgba(59,130,246,0.15) !important;
}
/* ── 버튼 ── */
#send-btn {
background: linear-gradient(135deg, #1d4ed8, #3b82f6) !important;
color: #ffffff !important;
border: none !important;
border-radius: 12px !important;
font-weight: 700 !important;
font-size: 0.95rem !important;
padding: 0 24px !important;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(29,78,216,0.3) !important;
}
#send-btn:hover {
background: linear-gradient(135deg, #1e40af, #2563eb) !important;
box-shadow: 0 4px 16px rgba(29,78,216,0.4) !important;
transform: translateY(-1px);
}
#clear-btn {
border: 1.5px solid #93c5fd !important;
color: #1d4ed8 !important;
border-radius: 12px !important;
font-weight: 600 !important;
background: #eff6ff !important;
transition: all 0.2s;
}
#clear-btn:hover {
background: #dbeafe !important;
}
/* ── 예시 질문 버튼 ── */
.example-group {
background: #f8faff;
border: 1px solid #dbeafe;
border-radius: 12px;
padding: 14px 16px;
margin-top: 12px;
}
.example-label {
color: #1e3a8a !important;
font-size: 0.82rem;
font-weight: 700;
margin-bottom: 8px;
}
"""
# 예시 질문 목록
EXAMPLE_QUESTIONS = [
"건조한 피부에 좋은 보습 제품 추천해줘",
"헤모힘이 뭐에 좋아?",
"세일즈 마스터 되려면 어떻게 해야 해?",
"다이아몬드 마스터 승급 조건 알려줘",
"면역력에 도움이 되는 건강식품 있어?",
"후원수당은 어떻게 계산해?",
]
with gr.Blocks(title="애터미 쇼핑몰 어시스턴트") as demo:
# ── 헤더 ──────────────────────────────────────────────────
gr.HTML("""
<div id="app-header">
<h1>🤖 애터미 쇼핑몰 어시스턴트</h1>
<p>상품 추천부터 마케팅 플랜까지, 무엇이든 자유롭게 물어보세요!</p>
<div class="badge-row">
<span class="badge">✨ Gemini AI</span>
<span class="badge">🔍 RAG 벡터 검색</span>
<span class="badge">💬 대화형 챗봇</span>
</div>
</div>
""")
# ── 대화 히스토리 상태 ────────────────────────────────────
chat_history = gr.State([])
# ── 챗봇 UI ───────────────────────────────────────────────
with gr.Column(elem_classes=["chat-container"]):
chatbot = gr.Chatbot(
value=[],
label="대화창",
show_label=False,
height=520,
elem_classes=["chatbot-box"],
)
# ── 입력 영역 ──────────────────────────────────────
with gr.Row(elem_id="chat-input-area"):
user_input = gr.Textbox(
placeholder="상품이나 마케팅 플랜에 대해 질문하세요... (Enter: 전송 / Shift+Enter: 줄바꿈)",
show_label=False,
lines=1,
max_lines=1,
scale=8,
elem_id="chat-input",
)
with gr.Column(scale=1, min_width=90):
send_btn = gr.Button("전송 ▶", variant="primary", elem_id="send-btn")
clear_btn = gr.Button("초기화", elem_id="clear-btn")
# ── 예시 질문 ─────────────────────────────────────────────
with gr.Column(elem_classes=["example-group"]):
gr.HTML('<div class="example-label">💡 이런 질문을 해보세요</div>')
with gr.Row():
for q in EXAMPLE_QUESTIONS[:3]:
gr.Button(q, size="sm").click(
fn=lambda x=q: x,
inputs=None,
outputs=user_input
)
with gr.Row():
for q in EXAMPLE_QUESTIONS[3:]:
gr.Button(q, size="sm").click(
fn=lambda x=q: x,
inputs=None,
outputs=user_input
)
# ============================================================
# 13. 이벤트 핸들러
# ============================================================
def on_send(message: str, history: list):
if not message.strip():
return history, history, ""
final_history = history
for updated, _ in chat(message, history):
final_history = updated
return final_history, final_history, ""
def on_clear():
return [], [], ""
send_btn.click(
fn=on_send,
inputs=[user_input, chat_history],
outputs=[chatbot, chat_history, user_input]
)
user_input.submit(
fn=on_send,
inputs=[user_input, chat_history],
outputs=[chatbot, chat_history, user_input]
)
clear_btn.click(
fn=on_clear,
inputs=None,
outputs=[chatbot, chat_history, user_input]
)
# ============================================================
# 14. 앱 실행
# 일반 실행 : python shop-assistant.py
# 디버그 모드 (hot-reload): gradio shop-assistant.py
# 또는 : python shop-assistant.py --debug
# ============================================================
import sys
DEBUG_MODE = "--debug" in sys.argv
if __name__ == "__main__":
demo.launch(
inbrowser=True,
css=CSS,
debug=DEBUG_MODE, # --debug 시 그라디오 내장 로그 활성화
)