# 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)