# app.py
import streamlit as st
import pandas as pd
import logic
import os
from datetime import datetime, timedelta, timezone
from wordcloud import WordCloud
import matplotlib.pyplot as plt
# [NEW] 3D 시각화를 위한 라이브러리
import plotly.express as px
from sklearn.decomposition import PCA
import numpy as np
# -------------------------------------------------------------------------
# 1. 페이지 기본 설정 & 세션 상태 초기화
# -------------------------------------------------------------------------
st.set_page_config(page_title="AI 한식 재료 추천", layout="wide")
st.title("🍳 AI 식재료 대체 추천 대시보드")
# [NEW] 포트폴리오 스타일 적용 (Nanum Pen Script & Theme)
st.markdown("""
""", unsafe_allow_html=True)
if 'voted_logs' not in st.session_state: st.session_state['voted_logs'] = set()
if "stopword_input_field" not in st.session_state: st.session_state["stopword_input_field"] = ""
if "board_nick_input" not in st.session_state: st.session_state["board_nick_input"] = ""
if "board_msg_input" not in st.session_state: st.session_state["board_msg_input"] = ""
if "feedback_input_field" not in st.session_state: st.session_state["feedback_input_field"] = ""
# -------------------------------------------------------------------------
# 2. 헬퍼 함수 및 다이얼로그
# -------------------------------------------------------------------------
def format_saving(score, is_multi=False):
prefix = "총 " if is_multi else ""
if score > 0: return f"🟢 {prefix}+{score}단계 (절감)"
elif score < 0: return f"🔴 {prefix}{score}단계 (비쌈)"
else: return "⚪ 동일 수준"
@st.dialog("🧠 AI 추천 알고리즘 작동 원리 상세", width="large")
def show_logic_dialog():
if os.path.exists("flowchart.png"):
st.image("flowchart.png", use_container_width=True)
try:
with open("docs/logic_explanation.md", "r", encoding="utf-8") as f:
markdown_text = f.read()
st.markdown("---")
st.markdown(markdown_text)
except:
st.error("설명 파일을 찾을 수 없습니다.")
@st.dialog("☁️ 검색 트렌드 워드클라우드", width="large")
def show_wordcloud_dialog(timeframe_text, text_data):
st.subheader(f"{timeframe_text} 많이 검색된 타겟 재료")
if not text_data:
st.info("데이터가 충분하지 않습니다.")
return
font_path = "src/font.ttf" if os.path.exists("src/font.ttf") else None
try:
wordcloud = WordCloud(font_path=font_path, width=800, height=400, background_color='white', colormap='viridis', random_state=42).generate(text_data)
fig, ax = plt.subplots(figsize=(10, 5))
ax.imshow(wordcloud, interpolation='bilinear'); ax.axis('off')
st.pyplot(fig)
if not font_path: st.caption("⚠️ 한글 폰트 파일이 없어 글자가 깨질 수 있습니다.")
except Exception as e: st.error(f"오류 발생: {e}")
# [NEW] 3D 벡터 공간 시각화 팝업
@st.dialog("🌌 재료 벡터 공간 (3D Visualization)", width="large")
def show_3d_space_dialog():
st.caption("AI가 학습한 재료들의 관계를 3차원 공간에서 확인해보세요. (상위 300개 재료)")
try:
# logic.py에서 로드된 Word2Vec 모델 가져오기
model = logic.w2v_model
# 빈도수 상위 300개 단어 추출
words = model.wv.index_to_key[:300]
vectors = np.array([model.wv[word] for word in words])
# PCA로 100차원 -> 3차원 축소
pca = PCA(n_components=3)
projections = pca.fit_transform(vectors)
# 데이터프레임 생성
df_vis = pd.DataFrame(projections, columns=['x', 'y', 'z'])
df_vis['word'] = words
# Plotly 3D 산점도 그리기
fig = px.scatter_3d(
df_vis, x='x', y='y', z='z',
text='word',
hover_name='word',
color='z', # 높이에 따라 색상 변화
color_continuous_scale='Viridis'
)
fig.update_traces(
marker=dict(size=4, opacity=0.8),
textposition='top center',
textfont=dict(size=10, color='black') # 텍스트 스타일
)
fig.update_layout(
height=600,
scene=dict(
xaxis=dict(showticklabels=False, title=''),
yaxis=dict(showticklabels=False, title=''),
zaxis=dict(showticklabels=False, title='')
),
margin=dict(l=0, r=0, b=0, t=0)
)
st.plotly_chart(fig, use_container_width=True)
st.info("💡 **팁:** 마우스로 드래그하여 회전하거나 휠로 확대/축소할 수 있습니다. 가까이 있는 재료들은 AI가 '비슷한 성질'로 인식한 것입니다.")
except Exception as e:
st.error(f"시각화 생성 실패: {e}")
# [CALLBACK] 함수들
def handle_board_submission():
nick = st.session_state.get("board_nick_input", "")
msg = st.session_state.get("board_msg_input", "")
if nick and msg:
if logic.save_board_message(nick, msg):
st.toast("게시글이 등록되었습니다!", icon="✅")
st.session_state["board_nick_input"] = ""
st.session_state["board_msg_input"] = ""
else: st.toast("게시글 등록에 실패했습니다.", icon="❌")
else: st.toast("닉네임과 내용을 모두 입력해주세요.", icon="⚠️")
def handle_stopword_submission():
current_input = st.session_state.get("stopword_input_field", "")
if current_input:
is_success, msg = logic.save_stopwords_to_db(current_input)
if is_success:
st.toast(msg, icon="✅")
st.session_state["stopword_input_field"] = ""
else: st.toast(msg, icon="❌")
else: st.toast("단어를 입력해주세요.", icon="⚠️")
def handle_feedback_submission():
content = st.session_state.get("feedback_input_field", "")
if content:
if logic.save_feedback_to_db(content):
st.toast("의견 감사합니다!", icon="✅")
st.balloons()
st.session_state["feedback_input_field"] = ""
else: st.toast("전송 실패", icon="❌")
else: st.toast("내용을 입력해주세요.", icon="⚠️")
# -------------------------------------------------------------------------
# 3. 사이드바 UI
# -------------------------------------------------------------------------
with st.sidebar:
st.header("🎛️ 컨트롤 패널")
selected_mode = st.radio("모드 선택", ["📚 Ver.1 기존 레시피 DB 검색", "✨ Ver.2 나만의 재료 입력 (커스텀)"], index=0)
st.divider()
st.subheader("⚖️ 가중치 설정")
is_v1 = selected_mode == "📚 Ver.1 기존 레시피 DB 검색"
w_w2v = st.slider("맛·성질 (Word2Vec)", 0.0, 5.0, 5.0, 0.5)
w_d2v = st.slider("문맥 (Doc2Vec)", 0.0, 5.0, 1.0, 0.5)
w_method = st.slider("조리법 통계 (Ver.1 전용)", 0.0, 5.0, 1.0, 0.5, disabled=not is_v1)
w_cat = st.slider("카테고리 통계 (Ver.1 전용)", 0.0, 5.0, 1.0, 0.5, disabled=not is_v1)
if not is_v1: st.caption("💡 커스텀 모드에서는 통계 가중치가 적용되지 않습니다.")
excluded_ingredients = []
if not is_v1:
st.divider()
st.subheader("🚫 제외할 재료 설정")
all_ing_options = sorted(list(logic.all_ingredients_set))
excluded_ingredients = st.multiselect("제외할 재료 선택", all_ing_options, placeholder="예: 땅콩, 오이")
st.divider()
# [NEW] 3D 시각화 버튼 추가
if st.button("🌌 재료 우주(3D) 탐험하기", use_container_width=True):
show_3d_space_dialog()
if st.button("🤔 어떤 과정을 거쳐 재료가 추천되나요?", use_container_width=True):
show_logic_dialog()
st.divider()
st.subheader("📊 인사이트 대시보드 (Beta)")
kst = timezone(timedelta(hours=9))
today_date_string = datetime.now(kst).strftime("%Y년 %m월 %d일")
stopwords_list = logic.load_global_stopwords()
tab_today, tab_all = st.tabs(["📅 오늘", "📈 누적"])
wc_text_today = logic.get_wordcloud_text('today')
wc_text_all = logic.get_wordcloud_text('all')
today_count, today_dishes, today_targets = logic.get_usage_stats(timeframe='today')
all_count, all_dishes, all_targets = logic.get_usage_stats(timeframe='all')
with tab_today:
st.caption(f"기준일: {today_date_string} (KST)")
col_m1, col_m2 = st.columns(2)
col_m1.metric("오늘 사용량", f"{today_count}건")
col_m2.metric("누적 불용어", f"{len(stopwords_list)}개")
if today_count > 0:
if st.button("☁️ 오늘의 워드클라우드", key="btn_wc_today", use_container_width=True):
show_wordcloud_dialog("오늘", wc_text_today)
st.caption("🔥 오늘 많이 대체된 재료")
if not today_targets.empty: st.bar_chart(today_targets, color="#FF6B6B", height=200)
else: st.info("오늘의 데이터가 없습니다.")
with tab_all:
st.caption("서비스 시작 이후 전체 데이터")
col_a1, col_a2 = st.columns(2)
col_a1.metric("총 사용량", f"{all_count}건")
col_a2.metric("누적 불용어", f"{len(stopwords_list)}개")
if all_count > 0:
if st.button("☁️ 누적 워드클라우드", key="btn_wc_all", use_container_width=True):
show_wordcloud_dialog("누적", wc_text_all)
st.caption("🔥 역대 많이 대체된 재료")
if not all_targets.empty: st.bar_chart(all_targets, color="#FF6B6B", height=200)
else: st.info("누적 데이터가 없습니다.")
with st.expander("📋 신고된 불용어 목록 보기"):
if stopwords_list: st.dataframe(pd.DataFrame(stopwords_list, columns=["불용어"]), use_container_width=True, hide_index=True)
else: st.info("신고된 불용어가 없습니다.")
st.divider()
with st.expander("💬 익명 게시판 (Beta)", expanded=True):
with st.form("board_form"):
st.text_input("닉네임", placeholder="익명", key="board_nick_input")
st.text_area("내용", placeholder="자유롭게 의견을 남겨주세요", height=80, key="board_msg_input")
st.form_submit_button("등록", on_click=handle_board_submission)
st.markdown("---")
messages = logic.get_board_messages()
if messages:
for m in messages:
st.markdown(f"**{m['nickname']}** ({m['display_time']})", unsafe_allow_html=True)
st.text(m['content'])
st.divider()
else: st.caption("첫 번째 글을 남겨보세요!")
# -------------------------------------------------------------------------
# 4. 메인 UI (기존과 동일)
# -------------------------------------------------------------------------
col_main, _ = st.columns([0.9, 0.1])
with col_main:
if selected_mode == "📚 Ver.1 기존 레시피 DB 검색":
st.markdown("""
[Ver.1] 레시피 데이터베이스에서 검색
학습된 12만여 개의 레시피 중 하나를 선택하여 분석합니다. 모든 통계 점수가 활용됩니다.
""", unsafe_allow_html=True)
search_keyword = st.text_input("🍽️ 요리명 검색 (키워드 입력 후 엔터)", placeholder="예: 된장찌개")
final_dish_name = None
if search_keyword:
exact_match = logic.df[logic.df['요리명'] == search_keyword]
exact_name = exact_match['요리명'].iloc[0] if not exact_match.empty else None
candidates = logic.df[logic.df['요리명'].str.contains(search_keyword, na=False, case=False)]
if exact_name: candidates = candidates[candidates['요리명'] != exact_name]
candidate_names = sorted(candidates['요리명'].unique().tolist())[:30]
options = []
if exact_name: options.append(exact_name)
options.extend(candidate_names)
if not options: st.warning(f"🔍 '{search_keyword}'가 포함된 요리명을 찾을 수 없습니다.")
else:
index_to_select = 0 if exact_name else None
label_msg = f"🔎 '{search_keyword}' 검색 결과 ({len(options)}개)"
if exact_name: label_msg += " - 정확한 요리명이 발견되었습니다!"
selected_option = st.selectbox(label_msg, options, index=index_to_select)
final_dish_name = selected_option
if final_dish_name:
st.success(f"✅ 선택된 요리: **{final_dish_name}**")
cands = logic.df[logic.df['요리명'] == final_dish_name]
cands = cands.head(10).reset_index(drop=True)
if cands.empty: st.error("❌ 레시피 정보를 불러올 수 없습니다.")
else:
st.divider()
options = {}
for _, r in cands.iterrows():
preview = ', '.join(r['재료토큰'])
options[f"[{r['요리방법별명']}] {r['요리명']} (ID:{r['레시피일련번호']}) - {preview}"] = r['레시피일련번호']
selected_label = st.selectbox("📜 분석할 레시피를 선택하세요", list(options.keys()))
recipe_id = options[selected_label]
c1, c2 = st.columns(2)
with c1: target_str = st.text_input("🎯 바꿀 재료", placeholder="돼지고기, 양파")
with c2: stop_str = st.text_input("🚫 제거할 문구", placeholder="약간, 시판용")
if target_str:
targets = [t.strip() for t in target_str.split(',') if t.strip()]
stops = [s.strip() for s in stop_str.split(',') if s.strip()]
current_recipe_row = logic.df[logic.df['레시피일련번호'] == recipe_id].iloc[0]
recipe_ingredients = current_recipe_row['재료토큰']
invalid_targets = [t for t in targets if t not in recipe_ingredients]
if not targets: st.warning("타겟 재료를 입력해주세요.")
elif invalid_targets:
st.error(f"🚨 다음 재료는 선택한 레시피에 없습니다: {', '.join(invalid_targets)}")
st.info("💡 팁: 레시피 미리보기에 있는 재료명을 정확히 입력해주세요.")
else:
st.divider()
has_result = False
final_recs = []
if len(targets) == 1:
st.subheader("🔹 단일 재료 대체 추천 (DB 기반)")
t = targets[0]
res = logic.substitute_single(recipe_id, t, stops, w_w2v, w_d2v, w_method, w_cat, topn=5)
st.markdown(f"**{t}** 대체 결과")
if not res.empty:
has_result = True
final_recs = res['대체재료'].head(3).tolist()
d_df = res[['대체재료', '최종점수', 'saving_score']].copy()
d_df['예상 원가변동'] = d_df['saving_score'].apply(lambda x: format_saving(x))
d_df = d_df[['대체재료', '최종점수', '예상 원가변동']]
d_df.columns = ['추천재료', '적합도', '예상 원가변동']
st.dataframe(d_df.style.format("{:.1%}", subset=['적합도']).background_gradient(cmap='Greens', subset=['적합도']), use_container_width=True, hide_index=True)
else: st.warning("결과 없음")
elif len(targets) > 1:
st.subheader("🧩 최적의 재료 조합 (DB 기반 다중 대체)")
multi_res = logic.substitute_multi(recipe_id, targets, stops, w_w2v, w_d2v, w_method, w_cat)
if multi_res:
has_result = True
final_recs = [", ".join(subs) for subs, score, saving in multi_res]
m_df = pd.DataFrame([(f"{', '.join(subs)}", score, format_saving(saving, True)) for subs, score, saving in multi_res], columns=['추천 조합', '종합 점수', '예상 원가변동 합계'])
st.dataframe(m_df.style.format("{:.1%}", subset=['종합 점수']).background_gradient(cmap='Blues', subset=['종합 점수']), use_container_width=True, hide_index=True)
else: st.info("조합을 찾을 수 없습니다.")
if has_result:
current_state = f"DB_{final_dish_name}_{target_str}_{stop_str}_{w_w2v}_{w_d2v}_{w_method}_{w_cat}_{final_recs}"
if 'last_log' not in st.session_state: st.session_state['last_log'] = ""
if st.session_state['last_log'] != current_state:
log_id = logic.save_log_to_db(final_dish_name, target_str, stops, w_w2v, w_d2v, w_method, w_cat, rec_list=final_recs, is_custom=False)
st.session_state['current_log_id'] = log_id
st.session_state['last_log'] = current_state
if 'current_log_id' in st.session_state and st.session_state['current_log_id']:
cl_id = st.session_state['current_log_id']
is_voted = cl_id in st.session_state['voted_logs']
st.write(""); b1, b2, _ = st.columns([0.2, 0.2, 0.6])
if is_voted: b1.success("✅ 평가 완료!"); b2.write("")
else:
b1.button("👍 만족해요", key="btn_sat_db", use_container_width=True, on_click=lambda: (logic.update_feedback_in_db(cl_id, "satisfy"), st.session_state['voted_logs'].add(cl_id), st.toast("감사합니다!")))
b2.button("👎 아쉬워요", key="btn_dis_db", use_container_width=True, on_click=lambda: (logic.update_feedback_in_db(cl_id, "dissatisfy"), st.session_state['voted_logs'].add(cl_id), st.toast("의견 감사합니다.")))
elif selected_mode == "✨ Ver.2 나만의 재료 입력 (커스텀)":
st.markdown("""[Ver.2] 나만의 재료 리스트 입력
냉장고 속 재료들을 직접 입력하세요. 문맥을 실시간으로 분석하여 추천합니다. (통계 점수 제외)
""", unsafe_allow_html=True)
st.markdown("##### 🏷️ 요리명 입력 (참고용)")
search_keyword_v2 = st.text_input("키워드 입력 후 엔터 (예: 볶음밥) - 선택사항", key="v2_search")
custom_dish_name = search_keyword_v2
if search_keyword_v2:
exact_match_v2 = logic.df[logic.df['요리명'] == search_keyword_v2]
exact_name_v2 = exact_match_v2['요리명'].iloc[0] if not exact_match_v2.empty else None
candidates_v2 = logic.df[logic.df['요리명'].str.contains(search_keyword_v2, na=False, case=False)]
if exact_name_v2: candidates_v2 = candidates_v2[candidates_v2['요리명'] != exact_name_v2]
candidate_names_v2 = sorted(candidates_v2['요리명'].unique().tolist())[:30]
options_v2 = []
if exact_name_v2: options_v2.append(exact_name_v2)
options_v2.append("(직접 입력한 이름 사용)")
options_v2.extend(candidate_names_v2)
if options_v2:
idx_v2 = 0 if exact_name_v2 else 0
label_v2 = f"💡 관련 요리명 발견 ({len(options_v2)-1}개)"
if exact_name_v2: label_v2 += " - 정확한 요리명 발견!"
sel_v2 = st.selectbox(label_v2, options_v2, index=idx_v2, key="v2_select")
if sel_v2 != "(직접 입력한 이름 사용)": custom_dish_name = sel_v2
st.write("")
context_str = st.text_area("📝 전체 재료 리스트 (쉼표로 구분)", placeholder="예: 밥, 계란, 대파, 간장, 참기름", height=100, key="v2_context")
if context_str:
context_ings_list = [ing.strip() for ing in context_str.split(',') if ing.strip()]
if not context_ings_list: st.warning("재료를 한 개 이상 입력해주세요.")
else:
st.caption(f"인식된 재료 ({len(context_ings_list)}개): {', '.join(context_ings_list)}")
c1_c, c2_c = st.columns(2)
with c1_c: target_str_c = st.text_input("🎯 바꿀 재료 (위 리스트 중)", placeholder="예: 계란", key="v2_target")
with c2_c: stop_str_c = st.text_input("🚫 제거할 문구 (임시)", placeholder="예: 약간", key="v2_stop")
if target_str_c:
targets_c = [t.strip() for t in target_str_c.split(',') if t.strip()]
stops_c = [s.strip() for s in stop_str_c.split(',') if s.strip()]
invalid_targets = [t for t in targets_c if t not in context_ings_list]
if invalid_targets: st.error(f"🚨 다음 재료는 전체 리스트에 없습니다: {', '.join(invalid_targets)}")
elif not targets_c: st.warning("바꿀 재료를 입력해주세요.")
else:
st.divider()
has_result_c = False
final_recs_c = []
if len(targets_c) == 1:
st.subheader("🔹 단일 재료 대체 추천 (커스텀)")
t_c = targets_c[0]
res_c = logic.substitute_single_custom(t_c, context_ings_list, stops_c, w_w2v, w_d2v, excluded_ings=excluded_ingredients, topn=5)
st.markdown(f"**{t_c}** 대체 결과")
if not res_c.empty:
has_result_c = True
final_recs_c = res_c['대체재료'].head(3).tolist()
d_df_c = res_c[['대체재료', '최종점수', 'saving_score']].copy()
d_df_c['예상 원가변동'] = d_df_c['saving_score'].apply(lambda x: format_saving(x))
d_df_c = d_df_c[['대체재료', '최종점수', '예상 원가변동']]
d_df_c.columns = ['추천재료', '적합도', '예상 원가변동']
st.dataframe(d_df_c.style.format("{:.1%}", subset=['적합도']).background_gradient(cmap='Greens', subset=['적합도']), use_container_width=True, hide_index=True)
else: st.warning("결과 없음")
elif len(targets_c) > 1:
st.subheader("🧩 최적의 재료 조합 (커스텀 다중 대체)")
multi_res_c = logic.substitute_multi_custom(targets_c, context_ings_list, stops_c, w_w2v, w_d2v, excluded_ings=excluded_ingredients)
if multi_res_c:
has_result_c = True
final_recs_c = [", ".join(subs) for subs, score, saving in multi_res_c]
m_df_c = pd.DataFrame([(f"{', '.join(subs)}", score, format_saving(saving, True)) for subs, score, saving in multi_res_c], columns=['추천 조합', '종합 점수', '예상 원가변동 합계'])
st.dataframe(m_df_c.style.format("{:.1%}", subset=['종합 점수']).background_gradient(cmap='Blues', subset=['종합 점수']), use_container_width=True, hide_index=True)
else: st.info("조합을 찾을 수 없습니다.")
if has_result_c:
current_state_c = f"Custom_{custom_dish_name}_{target_str_c}_{stop_str_c}_{w_w2v}_{w_d2v}_{final_recs_c}"
if 'last_log_c' not in st.session_state: st.session_state['last_log_c'] = ""
if st.session_state['last_log_c'] != current_state_c:
log_id_c = logic.save_log_to_db(custom_dish_name, target_str_c, stops_c, w_w2v, w_d2v, 0, 0, rec_list=final_recs_c, is_custom=True)
st.session_state['current_log_id_c'] = log_id_c
st.session_state['last_log_c'] = current_state_c
if 'current_log_id_c' in st.session_state and st.session_state['current_log_id_c']:
cl_id_c = st.session_state['current_log_id_c']
is_voted_c = cl_id_c in st.session_state['voted_logs']
st.write(""); b1_c, b2_c, _ = st.columns([0.2, 0.2, 0.6])
if is_voted_c: b1_c.success("✅ 평가 완료!"); b2_c.write("")
else:
b1_c.button("👍 만족해요", key="btn_sat_custom", use_container_width=True, on_click=lambda: (logic.update_feedback_in_db(cl_id_c, "satisfy"), st.session_state['voted_logs'].add(cl_id_c), st.toast("감사합니다!")))
b2_c.button("👎 아쉬워요", key="btn_dis_custom", use_container_width=True, on_click=lambda: (logic.update_feedback_in_db(cl_id_c, "dissatisfy"), st.session_state['voted_logs'].add(cl_id_c), st.toast("의견 감사합니다.")))
else: st.info("👆 전체 재료 리스트를 먼저 입력해주세요.")
# -------------------------------------------------------------------------
# 5. 하단 피드백 및 불용어 신고 영역
# -------------------------------------------------------------------------
st.divider()
col_feedback, col_stopword = st.columns(2)
with col_feedback:
st.subheader("📢 서비스 의견 보내기")
with st.form("feedback_form"):
text = st.text_area("개선할 점이나 버그가 있다면 알려주세요!", height=100, key="feedback_input_field")
st.form_submit_button("의견 보내기", use_container_width=True, on_click=handle_feedback_submission)
with col_stopword:
st.subheader("🚫 불용어(이상한 단어) 신고하기")
st.caption(
"추천 결과에 이상한 단어가 있나요? 신고해주시면 다음부터 제외됩니다.",
help="현재 학습 데이터에 포함된 불용어가 너무 많아 일일이 수작업으로 처리하기 어렵습니다. 😥 여러분의 신고가 모이면 데이터의 품질이 높아지고 추천 결과도 더 정확해집니다. 소중한 기여 부탁드립니다! 🙏"
)
st.info("💡 Tip: '간장or진간장' 같은 경우 'or'를 신고하면 '간장진간장'으로 합쳐져 추천에서 제외됩니다.")
with st.form("stopword_form"):
st.text_input("신고할 단어 입력 (쉼표로 구분)", placeholder="예: 면포, 황석어젓, 텃밭", key="stopword_input_field")
st.form_submit_button("신고하기", use_container_width=True, on_click=handle_stopword_submission)