๊ฐ•๋ฏผ๊ท 
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)