κ°•λ―Όκ· 
Refactor: Combine Backend and Frontend into Monorepo structure
9f03b39
# 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("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Nanum+Pen+Script&display=swap');
html, body, [class*="css"] {
font-family: 'Nanum Pen Script', cursive !important;
font-size: 1.25rem;
}
/* 제λͺ© 폰트 크기 ν‚€μš°κΈ° */
h1 { font-size: 3.5rem !important; color: #1e293b !important; }
h2 { font-size: 2.8rem !important; color: #334155 !important; }
h3 { font-size: 2.2rem !important; color: #475569 !important; }
/* λ°°κ²½ κ·ΈλΌλ°μ΄μ…˜ (ν¬νŠΈν΄λ¦¬μ˜€μ™€ μœ μ‚¬ν•˜κ²Œ) */
.stApp {
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%) !important;
}
/* μ„€λͺ… λ°•μŠ€ μŠ€νƒ€μΌ */
div[data-testid="stMarkdownContainer"] > div {
font-family: 'Nanum Pen Script', cursive !important;
}
/* λ²„νŠΌ μŠ€νƒ€μΌ */
.stButton > button {
background: #3b82f6 !important;
color: white !important;
border-radius: 12px !important;
border: none !important;
font-family: 'Nanum Pen Script', cursive !important;
font-size: 1.4rem !important;
padding: 0.5rem 1rem !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.stButton > button:hover {
transform: scale(1.05);
opacity: 0.9;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
/* μž…λ ₯ ν•„λ“œ μŠ€νƒ€μΌ */
.stTextInput > div > div > input, .stTextArea > div > div > textarea {
background-color: rgba(255, 255, 255, 0.9) !important;
border-radius: 10px !important;
border: 1px solid #cbd5e1 !important;
font-family: 'Nanum Pen Script', cursive !important;
font-size: 1.2rem !important;
color: #1e293b !important;
}
/* Expander μŠ€νƒ€μΌ */
.streamlit-expanderHeader {
font-family: 'Nanum Pen Script', cursive !important;
font-size: 1.3rem !important;
background-color: rgba(255, 255, 255, 0.5) !important;
border-radius: 8px !important;
}
</style>
""", 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']}** <span style='color:grey; font-size:0.8em;'>({m['display_time']})</span>", 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("""<div style="background-color: #f0f8ff; padding: 15px; border-radius: 10px; margin-bottom: 20px;"><h4 style="margin:0; color:#0066cc;">[Ver.1] λ ˆμ‹œν”Ό λ°μ΄ν„°λ² μ΄μŠ€μ—μ„œ 검색</h4><p style="margin:5px 0 0 0; font-size:14px;">ν•™μŠ΅λœ 12λ§Œμ—¬ 개의 λ ˆμ‹œν”Ό 쀑 ν•˜λ‚˜λ₯Ό μ„ νƒν•˜μ—¬ λΆ„μ„ν•©λ‹ˆλ‹€. λͺ¨λ“  톡계 μ μˆ˜κ°€ ν™œμš©λ©λ‹ˆλ‹€.</p></div>""", 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("""<div style="background-color: #fff5f0; padding: 15px; border-radius: 10px; margin-bottom: 20px;"><h4 style="margin:0; color:#cc5500;">[Ver.2] λ‚˜λ§Œμ˜ 재료 리슀트 μž…λ ₯</h4><p style="margin:5px 0 0 0; font-size:14px;">냉μž₯κ³  속 μž¬λ£Œλ“€μ„ 직접 μž…λ ₯ν•˜μ„Έμš”. λ¬Έλ§₯을 μ‹€μ‹œκ°„μœΌλ‘œ λΆ„μ„ν•˜μ—¬ μΆ”μ²œν•©λ‹ˆλ‹€. (톡계 점수 μ œμ™Έ)</p></div>""", 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)