๊ฐ•๋ฏผ๊ท  commited on
Commit
f0a4b63
ยท
0 Parent(s):

Initial commit for Hugging Face

Browse files
.dockerignore ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # flyctl launch added from .gitignore
2
+ # Byte-compiled / optimized / DLL files
3
+ **/__pycache__
4
+ **/*.py[cod]
5
+ **/*$py.class
6
+
7
+ # C extensions
8
+ **/*.so
9
+
10
+ # Distribution / packaging
11
+ **/.Python
12
+ **/build
13
+ **/develop-eggs
14
+ **/dist
15
+ **/downloads
16
+ **/eggs
17
+ **/.eggs
18
+ **/lib
19
+ **/lib64
20
+ **/parts
21
+ **/sdist
22
+ **/var
23
+ **/wheels
24
+ **/*.egg-info
25
+ **/.installed.cfg
26
+ **/*.egg
27
+
28
+ # PyInstaller
29
+ **/*.manifest
30
+ **/*.spec
31
+
32
+ # Installer logs
33
+ **/pip-log.txt
34
+ **/pip-delete-this-directory.txt
35
+
36
+ # Unit test / coverage reports
37
+ **/htmlcov
38
+ **/.tox
39
+ **/.coverage
40
+ **/.coverage.*
41
+ **/.cache
42
+ **/nosetests.xml
43
+ **/coverage.xml
44
+ **/*.cover
45
+ **/.hypothesis
46
+ **/.pytest_cache
47
+
48
+ # Translations
49
+ **/*.mo
50
+ **/*.pot
51
+
52
+ # Jupyter Notebook
53
+ **/.ipynb_checkpoints
54
+
55
+ # pyenv
56
+ **/.python-version
57
+
58
+ # Environments
59
+ **/.env
60
+ **/.venv
61
+ **/env
62
+ **/venv
63
+ **/ENV
64
+ **/env.bak
65
+ **/venv.bak
66
+
67
+ # IDE
68
+ **/.idea
69
+ **/.vscode
70
+ **/*.swp
71
+ **/*.swo
72
+ **/*~
73
+
74
+ # OS
75
+ **/.DS_Store
76
+ **/Thumbs.db
77
+
78
+ # Local settings
79
+ **/.streamlit/secrets.toml
80
+ fly.toml
.gitattributes ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ models/d2v.model filter=lfs diff=lfs merge=lfs -text
2
+ models/d2v.model.dv.vectors.npy filter=lfs diff=lfs merge=lfs -text
3
+ *.pkl filter=lfs diff=lfs merge=lfs -text
4
+ *.npy filter=lfs diff=lfs merge=lfs -text
5
+ *.model filter=lfs diff=lfs merge=lfs -text
6
+ models/w2v.model filter=lfs diff=lfs merge=lfs -text
7
+ *.csv filter=lfs diff=lfs merge=lfs -text
8
+ src/font.ttf filter=lfs diff=lfs merge=lfs -text
.github/workflows/fly-deploy.yml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/
2
+
3
+ name: Fly Deploy
4
+ on:
5
+ push:
6
+ branches:
7
+ - main
8
+ jobs:
9
+ deploy:
10
+ name: Deploy app
11
+ runs-on: ubuntu-latest
12
+ concurrency: deploy-group # optional: ensure only one action runs at a time
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: superfly/flyctl-actions/setup-flyctl@master
16
+ - run: flyctl deploy --remote-only
17
+ env:
18
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
.gitignore ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+
27
+ # PyInstaller
28
+ *.manifest
29
+ *.spec
30
+
31
+ # Installer logs
32
+ pip-log.txt
33
+ pip-delete-this-directory.txt
34
+
35
+ # Unit test / coverage reports
36
+ htmlcov/
37
+ .tox/
38
+ .coverage
39
+ .coverage.*
40
+ .cache
41
+ nosetests.xml
42
+ coverage.xml
43
+ *.cover
44
+ .hypothesis/
45
+ .pytest_cache/
46
+
47
+ # Translations
48
+ *.mo
49
+ *.pot
50
+
51
+ # Jupyter Notebook
52
+ .ipynb_checkpoints
53
+
54
+ # pyenv
55
+ .python-version
56
+
57
+ # Environments
58
+ .env
59
+ .venv
60
+ env/
61
+ venv/
62
+ ENV/
63
+ env.bak/
64
+ venv.bak/
65
+
66
+ # IDE
67
+ .idea/
68
+ .vscode/
69
+ *.swp
70
+ *.swo
71
+ *~
72
+
73
+ # OS
74
+ .DS_Store
75
+ Thumbs.db
76
+
77
+ # Local settings
78
+ .streamlit/secrets.toml
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ # ๋ณด์•ˆ ๋ฐ ๊ถŒํ•œ ์„ค์ •์„ ์œ„ํ•œ ์œ ์ € ์ƒ์„ฑ (Hugging Face ๊ถŒ์žฅ ์‚ฌํ•ญ)
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV PATH="/home/user/.local/bin:$PATH"
7
+
8
+ WORKDIR /app
9
+
10
+ # ์˜์กด์„ฑ ์„ค์น˜
11
+ COPY --chown=user requirements.txt requirements.txt
12
+ RUN pip install --no-cache-dir --upgrade pip && \
13
+ pip install --no-cache-dir -r requirements.txt
14
+
15
+ # ์ฝ”๋“œ ๋ณต์‚ฌ (๋ชจ๋ธ ํŒŒ์ผ ํฌํ•จ)
16
+ COPY --chown=user . .
17
+
18
+ # ํฌํŠธ ์„ค์ • (Hugging Face๋Š” 7860 ํฌํŠธ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค)
19
+ EXPOSE 7860
20
+
21
+ # ์‹คํ–‰ ๋ช…๋ น์–ด
22
+ CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ๐Ÿณ K-Recipe2Vec
2
+
3
+ AI ๊ธฐ๋ฐ˜ ํ•œ์‹ ์‹์žฌ๋ฃŒ ๋Œ€์ฒด ์ถ”์ฒœ ์„œ๋น„์Šค
4
+
5
+ ## ๐Ÿ“– Overview
6
+
7
+ ํ•œ๊ตญ ์š”๋ฆฌ์—์„œ ์‹์žฌ๋ฃŒ๋ฅผ ๋Œ€์ฒดํ•  ์ˆ˜ ์žˆ๋Š” ์žฌ๋ฃŒ๋ฅผ AI๊ฐ€ ์ถ”์ฒœํ•ด์ฃผ๋Š” ์›น ์„œ๋น„์Šค์ž…๋‹ˆ๋‹ค.
8
+ Doc2Vec๊ณผ Word2Vec ๋ชจ๋ธ์„ ํ™œ์šฉํ•˜์—ฌ ์‹์žฌ๋ฃŒ ๊ฐ„์˜ ์˜๋ฏธ์  ์œ ์‚ฌ๋„๋ฅผ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค.
9
+
10
+ ## ๐Ÿ”— Demo
11
+
12
+ ๐Ÿš€ **[Live Demo](https://korea-recipe-ai.streamlit.app/)** - Streamlit Cloud ๋ฐฐํฌ
13
+
14
+ ## โœจ Features
15
+
16
+ - **๐Ÿฅฌ ์‹์žฌ๋ฃŒ ๋Œ€์ฒด ์ถ”์ฒœ**: ์—†๋Š” ์žฌ๋ฃŒ์— ๋Œ€ํ•œ ์œ ์‚ฌ ์žฌ๋ฃŒ ์ถ”์ฒœ
17
+ - **๐Ÿ“Š 3D ์‹œ๊ฐํ™”**: PCA ๊ธฐ๋ฐ˜ ์žฌ๋ฃŒ ๋ฒกํ„ฐ ๊ณต๊ฐ„ ์‹œ๊ฐํ™”
18
+ - **๐Ÿ’ฐ ๊ฐ€๊ฒฉ ์ •๋ณด**: ์žฌ๋ฃŒ๋ณ„ ๊ฐ€๊ฒฉ ์ •๋ณด ์ œ๊ณต
19
+ - **โ˜๏ธ ์›Œ๋“œํด๋ผ์šฐ๋“œ**: ์ถ”์ฒœ ์žฌ๋ฃŒ ์‹œ๊ฐํ™”
20
+
21
+ ## ๐Ÿ› ๏ธ Tech Stack
22
+
23
+ | Category | Technologies |
24
+ |----------|-------------|
25
+ | **Frontend** | Streamlit |
26
+ | **ML Models** | Gensim (Doc2Vec, Word2Vec) |
27
+ | **Data Processing** | Pandas, NumPy |
28
+ | **Visualization** | Plotly, Matplotlib, WordCloud |
29
+ | **Database** | Supabase |
30
+ | **Deployment** | Streamlit Cloud |
31
+
32
+ ## ๐Ÿ“ Project Structure
33
+
34
+ ```
35
+ k-recipe2vec/
36
+ โ”œโ”€โ”€ app.py # Main Streamlit application
37
+ โ”œโ”€โ”€ logic.py # Core recommendation logic (if exists)
38
+ โ”œโ”€โ”€ requirements.txt # Python dependencies
39
+ โ”œโ”€โ”€ d2v.model # Doc2Vec trained model
40
+ โ”œโ”€โ”€ w2v.model # Word2Vec trained model
41
+ โ”œโ”€โ”€ price_rank.csv # Price data
42
+ โ””โ”€โ”€ stats.pkl # Preprocessed statistics
43
+ ```
44
+
45
+ ## ๐Ÿš€ Getting Started
46
+
47
+ ### Prerequisites
48
+
49
+ - Python 3.8+
50
+ - pip
51
+
52
+ ### Installation
53
+
54
+ ```bash
55
+ # Clone the repository
56
+ git clone https://github.com/nneans/k-recipe2vec.git
57
+ cd k-recipe2vec
58
+
59
+ # Install dependencies
60
+ pip install -r requirements.txt
61
+
62
+ # Run the app
63
+ streamlit run src/app.py
64
+ ```
65
+
66
+ ## ๐Ÿ“Š Model Information
67
+
68
+ ### Doc2Vec Model
69
+ - ํ•œ๊ตญ ๋ ˆ์‹œํ”ผ ๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜ ํ•™์Šต
70
+ - ๋ ˆ์‹œํ”ผ ๋‹จ์œ„ ๋ฌธ์„œ ์ž„๋ฒ ๋”ฉ
71
+
72
+ ### Word2Vec Model
73
+ - ์‹์žฌ๋ฃŒ ๊ฐ„ ์˜๋ฏธ์  ์œ ์‚ฌ๋„ ํ•™์Šต
74
+ - ๋Œ€์ฒด ๊ฐ€๋Šฅํ•œ ์žฌ๋ฃŒ ์ถ”์ฒœ์— ํ™œ์šฉ
75
+
76
+ ## ๐Ÿค Contributing
77
+
78
+ ๋ฒ„๊ทธ ๋ฆฌํฌํŠธ, ๊ธฐ๋Šฅ ์ œ์•ˆ, PR ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค!
79
+
80
+ ## ๐Ÿ“ License
81
+
82
+ MIT License
83
+
84
+ ## ๐Ÿ‘ค Author
85
+
86
+ **Mingyun Kang**
87
+ - GitHub: [@nneans](https://github.com/nneans)
data/final_recipe_data.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0f6379eeed75a6d8b2f44a8a3e713c0b3bb3f1af58d0ed92ee0b8a2523cf9e65
3
+ size 31853277
data/stats.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e2564ef8229041aadab57e0d4c371bb6ba781ceb0815390909fbc45851571304
3
+ size 14211436
docs/logic_explanation.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ### AI ์ถ”์ฒœ ๋กœ์ง ์ƒ์„ธ ํ•ด๋ถ€
2
+
3
+ ์ด ์‹œ์Šคํ…œ์€ 12๋งŒ์—ฌ ๊ฐœ์˜ ํ•œ์‹ ๋ ˆ์‹œํ”ผ ๋ฐ์ดํ„ฐ๋ฅผ ํ•™์Šตํ•œ AI๊ฐ€ ์žฌ๋ฃŒ์˜ ์˜๋ฏธ์™€ ๋ฌธ๋งฅ์„ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค. ๋‹จ์ˆœํžˆ ์ด๋ฆ„์ด ๋น„์Šทํ•œ ์žฌ๋ฃŒ๊ฐ€ ์•„๋‹Œ, **'์ง€๊ธˆ ์ด ์š”๋ฆฌ์— ๊ฐ€์žฅ ์ž˜ ์–ด์šธ๋ฆฌ๋Š”'** ์ตœ์ ์˜ ๋Œ€์•ˆ์„ ์ฐพ์•„๋‚ด๋Š” ๊ณผ์ •์ž…๋‹ˆ๋‹ค.
4
+
5
+ ---
6
+
7
+ #### ๐Ÿ’ก AI๋Š” ์žฌ๋ฃŒ๋ฅผ ์–ด๋–ป๊ฒŒ ์ดํ•ดํ• ๊นŒ์š”? (3์ฐจ์› ๋ฒกํ„ฐ ๊ณต๊ฐ„ ์˜ˆ์‹œ)
8
+ AI๋Š” ๋ชจ๋“  ์‹์žฌ๋ฃŒ๋ฅผ ๊ฑฐ๋Œ€ํ•œ 3์ฐจ์› ๊ณต๊ฐ„(์‹ค์ œ๋กœ๋Š” ์ˆ˜๋ฐฑ ์ฐจ์›) ์†์˜ **'์ขŒํ‘œ(๋ฒกํ„ฐ)'**๋กœ ์ธ์‹ํ•ฉ๋‹ˆ๋‹ค.
9
+ * **์œ ์‚ฌ๋„๊ฐ€ ๋†’๋‹ค๋Š” ๋œป:** ์ด ๊ณต๊ฐ„์—์„œ ๋‘ ์žฌ๋ฃŒ์˜ ์ขŒํ‘œ๊ฐ€ ์„œ๋กœ **๊ฐ€๊นŒ์šด ์œ„์น˜**์— ๋ชจ์—ฌ ์žˆ๊ฑฐ๋‚˜, ์›์ ์—์„œ ๋ป—์–ด๋‚˜๊ฐ€๋Š” **ํ™”์‚ดํ‘œ์˜ ๋ฐฉํ–ฅ์ด ๋น„์Šทํ•˜๋‹ค**๋Š” ์˜๋ฏธ์ž…๋‹ˆ๋‹ค.
10
+
11
+ ์œ„ ๊ทธ๋ฆผ์ฒ˜๋Ÿผ '๋ผ์ง€๊ณ ๊ธฐ'์™€ '์†Œ๊ณ ๊ธฐ'๋Š” '์œก๋ฅ˜'๋ผ๋Š” ๋น„์Šทํ•œ ์„ฑ์งˆ์„ ๊ฐ€์ ธ ๊ณต๊ฐ„์ƒ์—์„œ ๊ฐ€๊นŒ์šด ์œ„์น˜์— ๋ชจ์—ฌ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ˜๋ฉด, '์‚ฌ๊ณผ'๋Š” ์„ฑ์งˆ์ด ๋‹ค๋ฅด๊ธฐ ๋•Œ๋ฌธ์— ๋ฉ€๋ฆฌ ๋–จ์–ด์ ธ ์žˆ์Šต๋‹ˆ๋‹ค. AI๋Š” ์ด **'๊ฑฐ๋ฆฌ์™€ ๋ฐฉํ–ฅ์˜ ๋ฉ‚๊ณผ ๊ฐ€๊นŒ์›€'**์„ ๊ณ„์‚ฐํ•˜์—ฌ ์ถ”์ฒœ์— ํ™œ์šฉํ•ฉ๋‹ˆ๋‹ค.
12
+
13
+ ---
14
+
15
+ #### 1๋‹จ๊ณ„. ์˜๋ฏธ ํŒŒ์•… (Word2Vec): "์นœ๊ตฌ๋ฅผ ๋ณด๋ฉด ๋„ˆ๋ฅผ ์•Œ ์ˆ˜ ์žˆ์–ด"
16
+ * **ํ•ต์‹ฌ ์›๋ฆฌ:** AI๋Š” ์žฌ๋ฃŒ์˜ ๋ง›์ด๋‚˜ ์‹๊ฐ์„ ์ง์ ‘ ์•Œ์ง€ ๋ชปํ•ฉ๋‹ˆ๋‹ค. ๋Œ€์‹  **'ํ•จ๊ป˜ ์ž์ฃผ ์“ฐ์ด๋Š” ์ฃผ๋ณ€ ์žฌ๋ฃŒ(๋ฌธ๋งฅ)'**๊ฐ€ ๋น„์Šทํ• ์ˆ˜๋ก ์œ ์‚ฌํ•œ ์—ญํ• ์„ ํ•˜๋Š” ์žฌ๋ฃŒ๋กœ ํ•™์Šตํ•ฉ๋‹ˆ๋‹ค.
17
+ * **์˜ˆ์‹œ (ํƒ€๊ฒŸ ์žฌ๋ฃŒ: ๋ผ์ง€๊ณ ๊ธฐ)**
18
+ * **๋ผ์ง€๊ณ ๊ธฐ**์˜ ์นœ๊ตฌ๋“ค: [๊ฐ„์žฅ, ๋งˆ๋Š˜, ์–‘ํŒŒ, ๊ณ ์ถ”์žฅ, ๊น€์น˜, ๋ณถ๊ธฐ]
19
+ * ๐Ÿฅฉ **์†Œ๊ณ ๊ธฐ (์œ ์‚ฌ๋„ 0.85):** [๊ฐ„์žฅ, ๋งˆ๋Š˜, ์–‘ํŒŒ, ์ฐธ๊ธฐ๋ฆ„, ๋ถˆ๊ณ ๊ธฐ] โ†’ ๊ฒน์น˜๋Š” ์นœ๊ตฌ๊ฐ€ ๋งค์šฐ ๋งŽ์Œ (๋น„์Šทํ•œ ์žฌ๋ฃŒ!)
20
+ * ๐ŸŸ **๊ณ ๋“ฑ์–ด (์œ ์‚ฌ๋„ 0.45):** [๊ฐ„์žฅ, ๋งˆ๋Š˜, **๋ฌด, ์ƒ๊ฐ•, ๋น„๋ฆฐ๋‚ด**] โ†’ ์ผ๋ถ€ ๊ฒน์น˜์ง€๋งŒ, ๋‹ค๋ฅธ ์นœ๊ตฌ๋“ค๋„ ๋งŽ์Œ (์กฐ๊ธˆ ๋‹ค๋ฅธ ์žฌ๋ฃŒ)
21
+ * ๐ŸŽ **์‚ฌ๊ณผ (์œ ์‚ฌ๋„ 0.10):** [์„คํƒ•, **๋งˆ์š”๋„ค์ฆˆ, ์ƒ๋Ÿฌ๋“œ, ์•„์นจ**] โ†’ ๊ฒน์น˜๋Š” ์นœ๊ตฌ๊ฐ€ ๊ฑฐ์˜ ์—†์Œ (์™„์ „ํžˆ ๋‹ค๋ฅธ ์žฌ๋ฃŒ)
22
+
23
+ #### 2๋‹จ๊ณ„. ๋ฌธ๋งฅ ์ดํ•ด (Doc2Vec): "๊ฐ™์€ ์กฐ๋ฆฌ๋ฒ• ์ƒํ™ฉ์—์„œ์˜ ๊ถํ•ฉ ํŒŒ์•…"
24
+ * **ํ•ต์‹ฌ (์ฝ”๋“œ ๊ตฌํ˜„ ๋‚ด์šฉ):** ๋‹จ์ˆœํžˆ '์ด ์žฌ๋ฃŒ๊ฐ€ ์š”๋ฆฌ์— ์–ด์šธ๋ฆฌ๋‚˜?'๋ฅผ ๋ณด๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ, **'ํ˜„์žฌ์˜ ์กฐ๋ฆฌ๋ฐฉ๋ฒ•(์˜ˆ: ๋“์ด๊ธฐ, ๋ณถ๊ธฐ)'๊ณผ ๋™์ผํ•œ ์ƒํ™ฉ์—์„œ** ์ž˜ ์–ด์šธ๋ฆฌ๋Š”์ง€๋ฅผ ํŒ๋‹จํ•ฉ๋‹ˆ๋‹ค.
25
+ * **์ž‘๋™ ์›๋ฆฌ (Ver.1 DB ๋ชจ๋“œ ๊ธฐ์ค€):**
26
+ 1. ํ˜„์žฌ ํƒ€๊ฒŸ ์š”๋ฆฌ์˜ **'์กฐ๋ฆฌ๋ฐฉ๋ฒ•'(์˜ˆ: ๋“์ด๊ธฐ)**์„ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.
27
+ 2. ํ›„๋ณด ์žฌ๋ฃŒ๊ฐ€ ์‚ฌ์šฉ๋œ ์ˆ˜๋งŽ์€ ๋ ˆ์‹œํ”ผ ์ค‘, **๊ฐ™์€ ์กฐ๋ฆฌ๋ฐฉ๋ฒ•('๋“์ด๊ธฐ')์ด ์‚ฌ์šฉ๋œ ๋ ˆ์‹œํ”ผ๋“ค๋งŒ ๊ณจ๋ผ๋ƒ…๋‹ˆ๋‹ค.**
28
+ 3. ๊ณจ๋ผ๋‚ธ ๋ ˆ์‹œํ”ผ๋“ค์˜ ์ขŒํ‘œ๊ฐ€ ํ˜„์žฌ ํƒ€๊ฒŸ ์š”๋ฆฌ์˜ ์ขŒํ‘œ์™€ ์–ผ๋งˆ๋‚˜ ๊ฐ€๊นŒ์šด์ง€ ๋น„๊ตํ•ฉ๋‹ˆ๋‹ค.
29
+ * **์™œ ์ด๋ ‡๊ฒŒ ํ•˜๋‚˜์š”?** ๊ฐ™์€ ์žฌ๋ฃŒ๋ผ๋„ '๋ณถ์„ ๋•Œ'์™€ '๋“์ผ ๋•Œ'์˜ ์—ญํ• ์ด ๋‹ค๋ฅด๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์กฐ๋ฆฌ๋ฒ• ์กฐ๊ฑด์„ ๊ฑธ์–ด ๋” ์ •ํ™•ํ•œ ๋ฌธ๋งฅ ํŒŒ์•…์„ ํ•ฉ๋‹ˆ๋‹ค.
30
+
31
+ #### 3๋‹จ๊ณ„. ํ†ต๊ณ„์  ์ ํ•ฉ์„ฑ (Ver.1 DB ๋ชจ๋“œ ์ „์šฉ): "๋ฐ์ดํ„ฐ ๊ฒ€์ฆ (Lift)"
32
+ * **์—ญํ• :** ์‹ค์ œ ๋ฐ์ดํ„ฐ์—์„œ ํ•ด๋‹น ์žฌ๋ฃŒ๊ฐ€ ํŠน์ • **์กฐ๋ฆฌ๋ฒ•**์ด๋‚˜ **์š”๋ฆฌ ์นดํ…Œ๊ณ ๋ฆฌ**์— '์œ ๋…' ๋งŽ์ด ์“ฐ์ด๋Š”์ง€ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. (์—ฌ๊ธฐ์„œ ์นดํ…Œ๊ณ ๋ฆฌ ์ •๋ณด๋„ ํ•จ๊ป˜ ํ™œ์šฉ๋ฉ๋‹ˆ๋‹ค.)
33
+ * **ํ•ต์‹ฌ ๊ฐœ๋… (Lift, ํ–ฅ์ƒ๋„):** ํ‰๊ท ์ ์ธ ์‚ฌ์šฉ ํ™•๋ฅ  ๋Œ€๋น„, ํŠน์ • ์กฐ๊ฑด์—์„œ ์‚ฌ์šฉ ํ™•๋ฅ ์ด ์–ผ๋งˆ๋‚˜ ๋†’์•„์ง€๋Š”์ง€๋ฅผ ๋ด…๋‹ˆ๋‹ค. ๊ธฐ์ค€๊ฐ’์€ **1**์ž…๋‹ˆ๋‹ค.
34
+ * **ํŒ๋‹จ ๊ธฐ์ค€:**
35
+ * **Lift > 1 (์ถ”์ฒœ):** ํ‰๊ท ๋ณด๋‹ค ์ด ์กฐ๊ฑด์—์„œ ๋” ์ž์ฃผ ์“ฐ์ž„ (๊ถํ•ฉ์ด ์ข‹์Œ)
36
+ * **Lift โ‰ˆ 1 (๋ณดํ†ต):** ํ‰๊ท ์ ์ธ ์ˆ˜์ค€์œผ๋กœ ์“ฐ์ž„
37
+ * **Lift < 1 (๋น„์ถ”์ฒœ):** ํ‰๊ท ๋ณด๋‹ค ์ด ์กฐ๊ฑด์—์„œ ๋œ ์“ฐ์ž„ (๊ถํ•ฉ์ด ์•ˆ ์ข‹์Œ)
38
+ * **์˜ˆ์‹œ (์กฐ๋ฆฌ๋ฒ•: ๋“์ด๊ธฐ):** ๋‘๋ถ€(Lift **> 1**, ๋“์ผ ๋•Œ ํ•„์ˆ˜), ์ƒ์ถ”(Lift **< 1**, ๋“์ผ ๋•Œ ์•ˆ ์”€)
39
+
40
+ ---
41
+
42
+ #### ๐Ÿš€ ์ถ”์ฒœ ์•Œ๊ณ ๋ฆฌ์ฆ˜ ์‹ฌํ™”: ์–ด๋–ป๊ฒŒ ์ตœ์ ์˜ ์žฌ๋ฃŒ๋ฅผ ์ฐพ์•„๋‚ผ๊นŒ?
43
+
44
+ **1. ๋‹จ์ผ ์žฌ๋ฃŒ ๋Œ€์ฒด (Best N ์ฐพ๊ธฐ)**
45
+ * ์œ„ 1~3๋‹จ๊ณ„ ์ ์ˆ˜์— ๊ฐ€์ค‘์น˜๋ฅผ ์ ์šฉํ•œ **์ตœ์ข… ์ข…ํ•ฉ ์ ์ˆ˜**๋ฅผ ๊ณ„์‚ฐํ•˜๊ณ , ์ ์ˆ˜๊ฐ€ ๊ฐ€์žฅ ๋†’์€ ์ˆœ์„œ๋Œ€๋กœ ์ƒ์œ„ N๊ฐœ์˜ ์žฌ๋ฃŒ๋ฅผ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค.
46
+
47
+ **2. ๋‹ค์ค‘ ์žฌ๋ฃŒ ๋Œ€์ฒด (์ตœ์  ์กฐํ•ฉ ์ฐพ๊ธฐ - ๋น” ์„œ์น˜)**
48
+ * ์—ฌ๋Ÿฌ ์žฌ๋ฃŒ๋ฅผ ๋™์‹œ์— ๋ฐ”๊ฟ€ ๋•Œ๋Š” ๊ฒฝ์šฐ์˜ ์ˆ˜๊ฐ€ ํญ๋ฐœ์ ์œผ๋กœ ๋Š˜์–ด๋‚ฉ๋‹ˆ๋‹ค. ์ด๋•Œ ํšจ์œจ์ ์ธ ํƒ์ƒ‰์„ ์œ„ํ•ด **'๋น” ์„œ์น˜(Beam Search)'**๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
49
+ * **์ž‘๋™ ์›๋ฆฌ (๋งค ๋‹จ๊ณ„๋งˆ๋‹ค):**
50
+ 1. ํ˜„์žฌ๊นŒ์ง€ ๊ตฌ์„ฑ๋œ ์กฐํ•ฉ์— ์ƒˆ๋กœ์šด ์žฌ๋ฃŒ ํ›„๋ณด๋ฅผ ํ•˜๋‚˜์”ฉ ์ถ”๊ฐ€ํ•ด๋ด…๋‹ˆ๋‹ค.
51
+ 2. ์ƒˆ๋กœ์šด ์กฐํ•ฉ์˜ ์ ์ˆ˜๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. (์ ์ˆ˜ = ํ˜„์žฌ๊นŒ์ง€์˜ ์ ์ˆ˜ + ์ƒˆ ์žฌ๋ฃŒ์˜ AI ์ ์ˆ˜)
52
+ 3. ๋ชจ๋“  ํ›„๋ณด ์กฐํ•ฉ ์ค‘ **์ ์ˆ˜๊ฐ€ ๊ฐ€์žฅ ๋†’์€ ์ƒ์œ„ K๊ฐœ(Beam Width)์˜ ์กฐํ•ฉ๋งŒ ๋‚จ๊ธฐ๊ณ ** ๋‚˜๋จธ์ง€๋Š” ๋ฒ„๋ฆฝ๋‹ˆ๋‹ค.
53
+ 4. ์ด ๊ณผ์ •์„ ๋ชฉํ‘œํ•œ ์žฌ๋ฃŒ ์ˆ˜๋งŒํผ ๋ฐ˜๋ณตํ•˜์—ฌ ์ตœ์ข…์ ์œผ๋กœ ๊ฐ€์žฅ ์ข‹์€ ์กฐํ•ฉ์„ ์ฐพ์•„๋ƒ…๋‹ˆ๋‹ค.
54
+ > **๐Ÿ’ก ๋น„์œ :** ์–ด๋‘์šด ์ˆฒ์†์—์„œ ๋ณด๋ฌผ์„ ์ฐพ์„ ๋•Œ, ์—ฌ๋Ÿฌ ๊ฐˆ๋ž˜ ๊ธธ ์ค‘ ๊ฐ€์žฅ ๋ฐ์€ ๋น›์ด ๋น„์ถ”๋Š” ๊ธธ 3๊ณณ(K=3)๋งŒ ๊ณจ๋ผ์„œ ๊ณ„์† ๋”ฐ๋ผ๊ฐ€๋Š” ๊ฒƒ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.
55
+
56
+ ---
57
+
58
+ #### ๐Ÿ† ์ตœ์ข… ์ข…ํ•ฉ ์ ์ˆ˜ ๊ณ„์‚ฐ ์˜ˆ์‹œ (๊ฐ€์ค‘์น˜ ์ ์šฉ)
59
+ **(์‹œ๋‚˜๋ฆฌ์˜ค: ๊น€์น˜์ฐŒ๊ฐœ(๋“์ด๊ธฐ, ๊ตญ/ํƒ•)์—์„œ '๋ผ์ง€๊ณ ๊ธฐ' ๋Œ€์‹  '์ฐธ์น˜์บ”' ์ถ”์ฒœ ์‹œ)**
60
+
61
+ * **1. ์˜๋ฏธ ์ ์ˆ˜:** 0.70 ร— ๊ฐ€์ค‘์น˜ 5.0 = **3.50**
62
+ * **2. ๋ฌธ๋งฅ ์ ์ˆ˜:** 0.95 ร— ๊ฐ€์ค‘์น˜ 1.0 = **0.95** (๊ฐ™์€ '๋“์ด๊ธฐ' ์š”๋ฆฌ๋“ค๊ณผ ๋น„๊ต)
63
+ * **3. ์กฐ๋ฆฌ ํ†ต๊ณ„:** 0.90 ร— ๊ฐ€์ค‘์น˜ 1.0 = **0.90** ('๋“์ด๊ธฐ' ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ)
64
+ * **4. ๋ถ„๋ฅ˜ ํ†ต๊ณ„:** 0.85 ร— ๊ฐ€์ค‘์น˜ 1.0 = **0.85** ('๊ตญ/ํƒ•' ๋ฐ์ดํ„ฐ ๊ฒ€์ฆ)
65
+
66
+ ๐Ÿ‘‰ **์ด์ :** 3.50 + 0.95 + 0.90 + 0.85 = **6.20** / (์ด ๊ฐ€์ค‘์น˜ 8.0) = **์ตœ์ข… ์ ํ•ฉ๋„ 77.5%**
67
+
68
+ ---
69
+
70
+ #### ๐Ÿ’ฐ ์˜ˆ์ƒ ์›๊ฐ€ ๋ณ€๋™ (๋ณ„๋„ ๊ณ„์‚ฐ)
71
+ AI ์ ์ˆ˜์™€ ๋ณ„๊ฐœ๋กœ ์ œ๊ณต๋˜๋Š” ์ฐธ๊ณ  ์ •๋ณด์ž…๋‹ˆ๋‹ค. ์‹ค์‹œ๊ฐ„ ์ •ํ™•ํ•œ ์‹œ์„ธ๊ฐ€ ์•„๋‹Œ, **์‚ฌ์ „์— ์ •์˜๋œ ์žฌ๋ฃŒ๋ณ„ ์ƒ๋Œ€์  ๊ฐ€๊ฒฉ ๋“ฑ๊ธ‰(1~5๋“ฑ๊ธ‰)**์„ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์‚ฐ๋ฉ๋‹ˆ๋‹ค.
72
+ * **์˜ˆ:** ๋ผ์ง€๊ณ ๊ธฐ(4๋“ฑ๊ธ‰) โžก๏ธ ๋‘๋ถ€(2๋“ฑ๊ธ‰) ๋Œ€์ฒด ์‹œ `4 - 2 = +2` (๐ŸŸข ์ด +2๋‹จ๊ณ„ ์ ˆ๊ฐ ์˜ˆ์ƒ)
fly.toml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fly.toml app configuration file generated for k-recipe2vec
2
+ #
3
+ # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4
+ #
5
+
6
+ app = 'k-recipe2vec'
7
+ primary_region = 'sjc'
8
+
9
+ [build]
10
+
11
+ [http_service]
12
+ internal_port = 8080
13
+ force_https = true
14
+ auto_stop_machines = 'stop'
15
+ auto_start_machines = true
16
+ min_machines_running = 1
17
+ processes = ['app']
18
+
19
+ [[vm]]
20
+ memory = '512mb'
21
+ cpus = 1
22
+ cpu_kind = 'shared'
23
+
24
+ swap_size_mb = 512
25
+
26
+ [[services.http_checks]]
27
+ interval = "10s"
28
+ timeout = "5s"
29
+ grace_period = "600s" # ์ค‘์š”: ์•ฑ ์‹œ์ž‘ ๋Œ€๊ธฐ ์‹œ๊ฐ„์„ 10๋ถ„์œผ๋กœ ์„ค์ •
30
+ restart_limit = 0
31
+ method = "get"
32
+ path = "/"
models/d2v.model ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b5e9a9c8c6bc7acaa809206c065679f4535bd521da269e528cca50914276875b
3
+ size 10299708
models/d2v.model.dv.vectors.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d3b053c724d19a192980f81be997c4a5750588cff4ae50b5e86cdf985bafdf47
3
+ size 52294128
models/w2v.model ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:29715dd37205c6f72615f4dbd5cdf59ddd2a757aec82959ba1aa1f6c630496c2
3
+ size 4529859
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit
2
+ pandas
3
+ numpy
4
+ gensim
5
+ scikit-learn
6
+ matplotlib
7
+ supabase
8
+ wordcloud
9
+ plotly
10
+
11
+ fastapi
12
+ uvicorn
src/api.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from pydantic import BaseModel
4
+ from typing import List, Optional
5
+ import sys
6
+ import os
7
+ import pandas as pd
8
+ import numpy as np
9
+ import threading
10
+
11
+ # ๊ฐ™์€ ํด๋”์˜ logic.py ์ž„ํฌํŠธ๋ฅผ ์œ„ํ•ด ๊ฒฝ๋กœ ์ถ”๊ฐ€
12
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
13
+
14
+ import logic
15
+
16
+ app = FastAPI(title="K-Recipe2Vec API")
17
+
18
+ # CORS ์„ค์ •
19
+ app.add_middleware(
20
+ CORSMiddleware,
21
+ allow_origins=["*"],
22
+ allow_credentials=True,
23
+ allow_methods=["*"],
24
+ allow_headers=["*"],
25
+ )
26
+
27
+ # --- Request Models ---
28
+ class IngredientRequest(BaseModel):
29
+ recipe_id: int
30
+ target: List[str]
31
+ stopwords: List[str] = []
32
+ w_w2v: float = 0.5
33
+ w_d2v: float = 0.5
34
+ w_method: float = 0.0
35
+ w_cat: float = 0.0
36
+
37
+ class CustomContextRequest(BaseModel):
38
+ context_ings: List[str]
39
+ target: List[str]
40
+ stopwords: List[str] = []
41
+ w_w2v: float = 0.5
42
+ w_d2v: float = 0.5
43
+ excluded: List[str] = []
44
+
45
+ # --- Startup ---
46
+ @app.on_event("startup")
47
+ def startup_event():
48
+ # ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์Šค๋ ˆ๋“œ์—์„œ ๋ชจ๋ธ ๋กœ๋”ฉ ์‹œ์ž‘ (์•ฑ ์‹œ์ž‘์„ ๋ง‰์ง€ ์•Š์Œ)
49
+ threading.Thread(target=logic.load_resources).start()
50
+
51
+ # --- Endpoints ---
52
+
53
+ @app.get("/")
54
+ def health_check():
55
+ # ๋ชจ๋ธ ๋กœ๋”ฉ ์ƒํƒœ ํ™•์ธ
56
+ status = "loading" if logic.df is None else "ok"
57
+ return {"status": status, "service": "K-Recipe2Vec API"}
58
+
59
+ @app.get("/recipes/search")
60
+ def search_recipes(q: str):
61
+ """์š”๋ฆฌ๋ช…์œผ๋กœ ๋ ˆ์‹œํ”ผ ๊ฒ€์ƒ‰"""
62
+ logic.ensure_initialized() # ๋กœ๋”ฉ ๋Œ€๊ธฐ
63
+
64
+ if not q: return []
65
+ try:
66
+ mask = logic.df['์š”๋ฆฌ๋ช…'].str.contains(q, case=False, na=False)
67
+ results = logic.df.loc[mask, ['๋ ˆ์‹œํ”ผ์ผ๋ จ๋ฒˆํ˜ธ', '์š”๋ฆฌ๋ช…', '์žฌ๋ฃŒํ† ํฐ']].head(20)
68
+
69
+ output = []
70
+ for _, row in results.iterrows():
71
+ output.append({
72
+ "id": int(row['๋ ˆ์‹œํ”ผ์ผ๋ จ๋ฒˆํ˜ธ']),
73
+ "name": row['์š”๋ฆฌ๋ช…'],
74
+ "ingredients": row['์žฌ๋ฃŒํ† ํฐ']
75
+ })
76
+ return output
77
+ except Exception as e:
78
+ raise HTTPException(status_code=500, detail=str(e))
79
+
80
+ @app.get("/recipes/{recipe_id}")
81
+ def get_recipe_detail(recipe_id: int):
82
+ """๋ ˆ์‹œํ”ผ ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ"""
83
+ logic.ensure_initialized() # ๋กœ๋”ฉ ๋Œ€๊ธฐ
84
+
85
+ try:
86
+ row = logic.df[logic.df['๋ ˆ์‹œํ”ผ์ผ๋ จ๋ฒˆํ˜ธ'] == recipe_id]
87
+ if row.empty:
88
+ raise HTTPException(status_code=404, detail="Recipe not found")
89
+
90
+ row = row.iloc[0]
91
+ return {
92
+ "id": int(row['๋ ˆ์‹œํ”ผ์ผ๋ จ๋ฒˆํ˜ธ']),
93
+ "name": row['์š”๋ฆฌ๋ช…'],
94
+ "method": row['์š”๋ฆฌ๋ฐฉ๋ฒ•๋ณ„๋ช…'],
95
+ "category": row['์š”๋ฆฌ์ข…๋ฅ˜๋ณ„๋ช…_์„ธ๋ถ„ํ™”'],
96
+ "ingredients": row['์žฌ๋ฃŒํ† ํฐ']
97
+ }
98
+ except Exception as e:
99
+ raise HTTPException(status_code=500, detail=str(e))
100
+
101
+ @app.post("/recommend/db/single")
102
+ def recommend_db_single(req: IngredientRequest):
103
+ """DB ๋ ˆ์‹œํ”ผ ๊ธฐ๋ฐ˜ ๋‹จ์ผ ์žฌ๋ฃŒ ๋Œ€์ฒด"""
104
+ logic.ensure_initialized() # ๋กœ๋”ฉ ๋Œ€๊ธฐ
105
+
106
+ if not req.target:
107
+ raise HTTPException(status_code=400, detail="Target ingredient required")
108
+ try:
109
+ df = logic.substitute_single(
110
+ req.recipe_id, req.target[0], req.stopwords,
111
+ req.w_w2v, req.w_d2v, req.w_method, req.w_cat
112
+ )
113
+ if df.empty: return []
114
+ df = df.replace([np.inf, -np.inf], 0).fillna(0)
115
+ return df.to_dict(orient="records")
116
+ except Exception as e:
117
+ raise HTTPException(status_code=500, detail=str(e))
118
+
119
+ @app.post("/recommend/db/multi")
120
+ def recommend_db_multi(req: IngredientRequest):
121
+ """DB ๋ ˆ์‹œํ”ผ ๊ธฐ๋ฐ˜ ๋‹ค์ค‘ ์žฌ๋ฃŒ ๋Œ€์ฒด"""
122
+ logic.ensure_initialized() # ๋กœ๋”ฉ ๋Œ€๊ธฐ
123
+
124
+ try:
125
+ results = logic.substitute_multi(
126
+ req.recipe_id, req.target, req.stopwords,
127
+ req.w_w2v, req.w_d2v, req.w_method, req.w_cat
128
+ )
129
+ formatted = []
130
+ for subs, score, saving in results:
131
+ formatted.append({
132
+ "substitutes": subs,
133
+ "score": float(score),
134
+ "saving_score": int(saving)
135
+ })
136
+ return formatted
137
+ except Exception as e:
138
+ raise HTTPException(status_code=500, detail=str(e))
139
+
140
+ @app.post("/recommend/custom/single")
141
+ def recommend_custom_single(req: CustomContextRequest):
142
+ """์‚ฌ์šฉ์ž ์ •์˜ ์žฌ๋ฃŒ ๊ธฐ๋ฐ˜ ๋‹จ์ผ ๋Œ€์ฒด"""
143
+ logic.ensure_initialized() # ๋กœ๋”ฉ ๋Œ€๊ธฐ
144
+
145
+ if not req.target:
146
+ raise HTTPException(status_code=400, detail="Target ingredient required")
147
+ try:
148
+ df = logic.substitute_single_custom(
149
+ req.target[0], req.context_ings, req.stopwords,
150
+ req.w_w2v, req.w_d2v, excluded_ings=req.excluded
151
+ )
152
+ if df.empty: return []
153
+ df = df.replace([np.inf, -np.inf], 0).fillna(0)
154
+ return df.to_dict(orient="records")
155
+ except Exception as e:
156
+ raise HTTPException(status_code=500, detail=str(e))
src/app.py ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ import streamlit as st
3
+ import pandas as pd
4
+ import logic
5
+ import os
6
+ from datetime import datetime, timedelta, timezone
7
+ from wordcloud import WordCloud
8
+ import matplotlib.pyplot as plt
9
+ # [NEW] 3D ์‹œ๊ฐํ™”๋ฅผ ์œ„ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ
10
+ import plotly.express as px
11
+ from sklearn.decomposition import PCA
12
+ import numpy as np
13
+
14
+ # -------------------------------------------------------------------------
15
+ # 1. ํŽ˜์ด์ง€ ๊ธฐ๋ณธ ์„ค์ • & ์„ธ์…˜ ์ƒํƒœ ์ดˆ๊ธฐํ™”
16
+ # -------------------------------------------------------------------------
17
+ st.set_page_config(page_title="AI ํ•œ์‹ ์žฌ๋ฃŒ ์ถ”์ฒœ", layout="wide")
18
+ st.title("๐Ÿณ AI ์‹์žฌ๋ฃŒ ๋Œ€์ฒด ์ถ”์ฒœ ๋Œ€์‹œ๋ณด๋“œ")
19
+
20
+ # [NEW] ํฌํŠธํด๋ฆฌ์˜ค ์Šคํƒ€์ผ ์ ์šฉ (Nanum Pen Script & Theme)
21
+ st.markdown("""
22
+ <style>
23
+ @import url('https://fonts.googleapis.com/css2?family=Nanum+Pen+Script&display=swap');
24
+
25
+ html, body, [class*="css"] {
26
+ font-family: 'Nanum Pen Script', cursive !important;
27
+ font-size: 1.25rem;
28
+ }
29
+
30
+ /* ์ œ๋ชฉ ํฐํŠธ ํฌ๊ธฐ ํ‚ค์šฐ๊ธฐ */
31
+ h1 { font-size: 3.5rem !important; color: #1e293b !important; }
32
+ h2 { font-size: 2.8rem !important; color: #334155 !important; }
33
+ h3 { font-size: 2.2rem !important; color: #475569 !important; }
34
+
35
+ /* ๋ฐฐ๊ฒฝ ๊ทธ๋ผ๋ฐ์ด์…˜ (ํฌํŠธํด๋ฆฌ์˜ค์™€ ์œ ์‚ฌํ•˜๊ฒŒ) */
36
+ .stApp {
37
+ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%) !important;
38
+ }
39
+
40
+ /* ์„ค๋ช… ๋ฐ•์Šค ์Šคํƒ€์ผ */
41
+ div[data-testid="stMarkdownContainer"] > div {
42
+ font-family: 'Nanum Pen Script', cursive !important;
43
+ }
44
+
45
+ /* ๋ฒ„ํŠผ ์Šคํƒ€์ผ */
46
+ .stButton > button {
47
+ background: #3b82f6 !important;
48
+ color: white !important;
49
+ border-radius: 12px !important;
50
+ border: none !important;
51
+ font-family: 'Nanum Pen Script', cursive !important;
52
+ font-size: 1.4rem !important;
53
+ padding: 0.5rem 1rem !important;
54
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
55
+ transition: all 0.2s;
56
+ }
57
+ .stButton > button:hover {
58
+ transform: scale(1.05);
59
+ opacity: 0.9;
60
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
61
+ }
62
+
63
+ /* ์ž…๋ ฅ ํ•„๋“œ ์Šคํƒ€์ผ */
64
+ .stTextInput > div > div > input, .stTextArea > div > div > textarea {
65
+ background-color: rgba(255, 255, 255, 0.9) !important;
66
+ border-radius: 10px !important;
67
+ border: 1px solid #cbd5e1 !important;
68
+ font-family: 'Nanum Pen Script', cursive !important;
69
+ font-size: 1.2rem !important;
70
+ color: #1e293b !important;
71
+ }
72
+
73
+ /* Expander ์Šคํƒ€์ผ */
74
+ .streamlit-expanderHeader {
75
+ font-family: 'Nanum Pen Script', cursive !important;
76
+ font-size: 1.3rem !important;
77
+ background-color: rgba(255, 255, 255, 0.5) !important;
78
+ border-radius: 8px !important;
79
+ }
80
+ </style>
81
+ """, unsafe_allow_html=True)
82
+
83
+ if 'voted_logs' not in st.session_state: st.session_state['voted_logs'] = set()
84
+ if "stopword_input_field" not in st.session_state: st.session_state["stopword_input_field"] = ""
85
+ if "board_nick_input" not in st.session_state: st.session_state["board_nick_input"] = ""
86
+ if "board_msg_input" not in st.session_state: st.session_state["board_msg_input"] = ""
87
+ if "feedback_input_field" not in st.session_state: st.session_state["feedback_input_field"] = ""
88
+
89
+ # -------------------------------------------------------------------------
90
+ # 2. ํ—ฌํผ ํ•จ์ˆ˜ ๋ฐ ๋‹ค์ด์–ผ๋กœ๊ทธ
91
+ # -------------------------------------------------------------------------
92
+ def format_saving(score, is_multi=False):
93
+ prefix = "์ด " if is_multi else ""
94
+ if score > 0: return f"๐ŸŸข {prefix}+{score}๋‹จ๊ณ„ (์ ˆ๊ฐ)"
95
+ elif score < 0: return f"๐Ÿ”ด {prefix}{score}๋‹จ๊ณ„ (๋น„์Œˆ)"
96
+ else: return "โšช ๋™์ผ ์ˆ˜์ค€"
97
+
98
+ @st.dialog("๐Ÿง  AI ์ถ”์ฒœ ์•Œ๊ณ ๋ฆฌ์ฆ˜ ์ž‘๋™ ์›๋ฆฌ ์ƒ์„ธ", width="large")
99
+ def show_logic_dialog():
100
+ if os.path.exists("flowchart.png"):
101
+ st.image("flowchart.png", use_container_width=True)
102
+ try:
103
+ with open("docs/logic_explanation.md", "r", encoding="utf-8") as f:
104
+ markdown_text = f.read()
105
+ st.markdown("---")
106
+ st.markdown(markdown_text)
107
+ except:
108
+ st.error("์„ค๋ช… ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
109
+
110
+ @st.dialog("โ˜๏ธ ๊ฒ€์ƒ‰ ํŠธ๋ Œ๋“œ ์›Œ๋“œํด๋ผ์šฐ๋“œ", width="large")
111
+ def show_wordcloud_dialog(timeframe_text, text_data):
112
+ st.subheader(f"{timeframe_text} ๋งŽ์ด ๊ฒ€์ƒ‰๋œ ํƒ€๊ฒŸ ์žฌ๋ฃŒ")
113
+ if not text_data:
114
+ st.info("๋ฐ์ดํ„ฐ๊ฐ€ ์ถฉ๋ถ„ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")
115
+ return
116
+ font_path = "src/font.ttf" if os.path.exists("src/font.ttf") else None
117
+ try:
118
+ wordcloud = WordCloud(font_path=font_path, width=800, height=400, background_color='white', colormap='viridis', random_state=42).generate(text_data)
119
+ fig, ax = plt.subplots(figsize=(10, 5))
120
+ ax.imshow(wordcloud, interpolation='bilinear'); ax.axis('off')
121
+ st.pyplot(fig)
122
+ if not font_path: st.caption("โš ๏ธ ํ•œ๊ธ€ ํฐํŠธ ํŒŒ์ผ์ด ์—†์–ด ๊ธ€์ž๊ฐ€ ๊นจ์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.")
123
+ except Exception as e: st.error(f"์˜ค๋ฅ˜ ๋ฐœ์ƒ: {e}")
124
+
125
+ # [NEW] 3D ๋ฒกํ„ฐ ๊ณต๊ฐ„ ์‹œ๊ฐํ™” ํŒ์—…
126
+ @st.dialog("๐ŸŒŒ ์žฌ๋ฃŒ ๋ฒกํ„ฐ ๊ณต๊ฐ„ (3D Visualization)", width="large")
127
+ def show_3d_space_dialog():
128
+ st.caption("AI๊ฐ€ ํ•™์Šตํ•œ ์žฌ๋ฃŒ๋“ค์˜ ๊ด€๊ณ„๋ฅผ 3์ฐจ์› ๊ณต๊ฐ„์—์„œ ํ™•์ธํ•ด๋ณด์„ธ์š”. (์ƒ์œ„ 300๊ฐœ ์žฌ๋ฃŒ)")
129
+
130
+ try:
131
+ # logic.py์—์„œ ๋กœ๋“œ๋œ Word2Vec ๋ชจ๋ธ ๊ฐ€์ ธ์˜ค๊ธฐ
132
+ model = logic.w2v_model
133
+
134
+ # ๋นˆ๋„์ˆ˜ ์ƒ์œ„ 300๊ฐœ ๋‹จ์–ด ์ถ”์ถœ
135
+ words = model.wv.index_to_key[:300]
136
+ vectors = np.array([model.wv[word] for word in words])
137
+
138
+ # PCA๋กœ 100์ฐจ์› -> 3์ฐจ์› ์ถ•์†Œ
139
+ pca = PCA(n_components=3)
140
+ projections = pca.fit_transform(vectors)
141
+
142
+ # ๋ฐ์ดํ„ฐํ”„๋ ˆ์ž„ ์ƒ์„ฑ
143
+ df_vis = pd.DataFrame(projections, columns=['x', 'y', 'z'])
144
+ df_vis['word'] = words
145
+
146
+ # Plotly 3D ์‚ฐ์ ๋„ ๊ทธ๋ฆฌ๊ธฐ
147
+ fig = px.scatter_3d(
148
+ df_vis, x='x', y='y', z='z',
149
+ text='word',
150
+ hover_name='word',
151
+ color='z', # ๋†’์ด์— ๋”ฐ๋ผ ์ƒ‰์ƒ ๋ณ€ํ™”
152
+ color_continuous_scale='Viridis'
153
+ )
154
+
155
+ fig.update_traces(
156
+ marker=dict(size=4, opacity=0.8),
157
+ textposition='top center',
158
+ textfont=dict(size=10, color='black') # ํ…์ŠคํŠธ ์Šคํƒ€์ผ
159
+ )
160
+
161
+ fig.update_layout(
162
+ height=600,
163
+ scene=dict(
164
+ xaxis=dict(showticklabels=False, title=''),
165
+ yaxis=dict(showticklabels=False, title=''),
166
+ zaxis=dict(showticklabels=False, title='')
167
+ ),
168
+ margin=dict(l=0, r=0, b=0, t=0)
169
+ )
170
+
171
+ st.plotly_chart(fig, use_container_width=True)
172
+ st.info("๐Ÿ’ก **ํŒ:** ๋งˆ์šฐ์Šค๋กœ ๋“œ๋ž˜๊ทธํ•˜์—ฌ ํšŒ์ „ํ•˜๊ฑฐ๋‚˜ ํœ ๋กœ ํ™•๋Œ€/์ถ•์†Œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฐ€๊นŒ์ด ์žˆ๋Š” ์žฌ๋ฃŒ๋“ค์€ AI๊ฐ€ '๋น„์Šทํ•œ ์„ฑ์งˆ'๋กœ ์ธ์‹ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค.")
173
+
174
+ except Exception as e:
175
+ st.error(f"์‹œ๊ฐํ™” ์ƒ์„ฑ ์‹คํŒจ: {e}")
176
+
177
+ # [CALLBACK] ํ•จ์ˆ˜๋“ค
178
+ def handle_board_submission():
179
+ nick = st.session_state.get("board_nick_input", "")
180
+ msg = st.session_state.get("board_msg_input", "")
181
+ if nick and msg:
182
+ if logic.save_board_message(nick, msg):
183
+ st.toast("๊ฒŒ์‹œ๊ธ€์ด ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค!", icon="โœ…")
184
+ st.session_state["board_nick_input"] = ""
185
+ st.session_state["board_msg_input"] = ""
186
+ else: st.toast("๊ฒŒ์‹œ๊ธ€ ๋“ฑ๋ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.", icon="โŒ")
187
+ else: st.toast("๋‹‰๋„ค์ž„๊ณผ ๋‚ด์šฉ์„ ๋ชจ๋‘ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.", icon="โš ๏ธ")
188
+
189
+ def handle_stopword_submission():
190
+ current_input = st.session_state.get("stopword_input_field", "")
191
+ if current_input:
192
+ is_success, msg = logic.save_stopwords_to_db(current_input)
193
+ if is_success:
194
+ st.toast(msg, icon="โœ…")
195
+ st.session_state["stopword_input_field"] = ""
196
+ else: st.toast(msg, icon="โŒ")
197
+ else: st.toast("๋‹จ์–ด๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.", icon="โš ๏ธ")
198
+
199
+ def handle_feedback_submission():
200
+ content = st.session_state.get("feedback_input_field", "")
201
+ if content:
202
+ if logic.save_feedback_to_db(content):
203
+ st.toast("์˜๊ฒฌ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค!", icon="โœ…")
204
+ st.balloons()
205
+ st.session_state["feedback_input_field"] = ""
206
+ else: st.toast("์ „์†ก ์‹คํŒจ", icon="โŒ")
207
+ else: st.toast("๋‚ด์šฉ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.", icon="โš ๏ธ")
208
+
209
+ # -------------------------------------------------------------------------
210
+ # 3. ์‚ฌ์ด๋“œ๋ฐ” UI
211
+ # -------------------------------------------------------------------------
212
+ with st.sidebar:
213
+ st.header("๐ŸŽ›๏ธ ์ปจํŠธ๋กค ํŒจ๋„")
214
+ selected_mode = st.radio("๋ชจ๋“œ ์„ ํƒ", ["๐Ÿ“š Ver.1 ๊ธฐ์กด ๋ ˆ์‹œํ”ผ DB ๊ฒ€์ƒ‰", "โœจ Ver.2 ๋‚˜๋งŒ์˜ ์žฌ๋ฃŒ ์ž…๋ ฅ (์ปค์Šคํ…€)"], index=0)
215
+ st.divider()
216
+ st.subheader("โš–๏ธ ๊ฐ€์ค‘์น˜ ์„ค์ •")
217
+ is_v1 = selected_mode == "๐Ÿ“š Ver.1 ๊ธฐ์กด ๋ ˆ์‹œํ”ผ DB ๊ฒ€์ƒ‰"
218
+ w_w2v = st.slider("๋ง›ยท์„ฑ์งˆ (Word2Vec)", 0.0, 5.0, 5.0, 0.5)
219
+ w_d2v = st.slider("๋ฌธ๋งฅ (Doc2Vec)", 0.0, 5.0, 1.0, 0.5)
220
+ w_method = st.slider("์กฐ๋ฆฌ๋ฒ• ํ†ต๊ณ„ (Ver.1 ์ „์šฉ)", 0.0, 5.0, 1.0, 0.5, disabled=not is_v1)
221
+ w_cat = st.slider("์นดํ…Œ๊ณ ๋ฆฌ ํ†ต๊ณ„ (Ver.1 ์ „์šฉ)", 0.0, 5.0, 1.0, 0.5, disabled=not is_v1)
222
+ if not is_v1: st.caption("๐Ÿ’ก ์ปค์Šคํ…€ ๋ชจ๋“œ์—์„œ๋Š” ํ†ต๊ณ„ ๊ฐ€์ค‘์น˜๊ฐ€ ์ ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")
223
+
224
+ excluded_ingredients = []
225
+ if not is_v1:
226
+ st.divider()
227
+ st.subheader("๐Ÿšซ ์ œ์™ธํ•  ์žฌ๋ฃŒ ์„ค์ •")
228
+ all_ing_options = sorted(list(logic.all_ingredients_set))
229
+ excluded_ingredients = st.multiselect("์ œ์™ธํ•  ์žฌ๋ฃŒ ์„ ํƒ", all_ing_options, placeholder="์˜ˆ: ๋•…์ฝฉ, ์˜ค์ด")
230
+
231
+ st.divider()
232
+ # [NEW] 3D ์‹œ๊ฐํ™” ๋ฒ„ํŠผ ์ถ”๊ฐ€
233
+ if st.button("๐ŸŒŒ ์žฌ๋ฃŒ ์šฐ์ฃผ(3D) ํƒํ—˜ํ•˜๊ธฐ", use_container_width=True):
234
+ show_3d_space_dialog()
235
+
236
+ if st.button("๐Ÿค” ์–ด๋–ค ๊ณผ์ •์„ ๊ฑฐ์ณ ์žฌ๋ฃŒ๊ฐ€ ์ถ”์ฒœ๋˜๋‚˜์š”?", use_container_width=True):
237
+ show_logic_dialog()
238
+
239
+ st.divider()
240
+ st.subheader("๐Ÿ“Š ์ธ์‚ฌ์ดํŠธ ๋Œ€์‹œ๋ณด๋“œ (Beta)")
241
+ kst = timezone(timedelta(hours=9))
242
+ today_date_string = datetime.now(kst).strftime("%Y๋…„ %m์›” %d์ผ")
243
+ stopwords_list = logic.load_global_stopwords()
244
+
245
+ tab_today, tab_all = st.tabs(["๐Ÿ“… ์˜ค๋Š˜", "๐Ÿ“ˆ ๋ˆ„์ "])
246
+
247
+ wc_text_today = logic.get_wordcloud_text('today')
248
+ wc_text_all = logic.get_wordcloud_text('all')
249
+ today_count, today_dishes, today_targets = logic.get_usage_stats(timeframe='today')
250
+ all_count, all_dishes, all_targets = logic.get_usage_stats(timeframe='all')
251
+
252
+ with tab_today:
253
+ st.caption(f"๊ธฐ์ค€์ผ: {today_date_string} (KST)")
254
+ col_m1, col_m2 = st.columns(2)
255
+ col_m1.metric("์˜ค๋Š˜ ์‚ฌ์šฉ๋Ÿ‰", f"{today_count}๊ฑด")
256
+ col_m2.metric("๋ˆ„์  ๋ถˆ์šฉ์–ด", f"{len(stopwords_list)}๊ฐœ")
257
+ if today_count > 0:
258
+ if st.button("โ˜๏ธ ์˜ค๋Š˜์˜ ์›Œ๋“œํด๋ผ์šฐ๋“œ", key="btn_wc_today", use_container_width=True):
259
+ show_wordcloud_dialog("์˜ค๋Š˜", wc_text_today)
260
+ st.caption("๐Ÿ”ฅ ์˜ค๋Š˜ ๋งŽ์ด ๋Œ€์ฒด๋œ ์žฌ๋ฃŒ")
261
+ if not today_targets.empty: st.bar_chart(today_targets, color="#FF6B6B", height=200)
262
+ else: st.info("์˜ค๋Š˜์˜ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
263
+
264
+ with tab_all:
265
+ st.caption("์„œ๋น„์Šค ์‹œ์ž‘ ์ดํ›„ ์ „์ฒด ๋ฐ์ดํ„ฐ")
266
+ col_a1, col_a2 = st.columns(2)
267
+ col_a1.metric("์ด ์‚ฌ์šฉ๋Ÿ‰", f"{all_count}๊ฑด")
268
+ col_a2.metric("๋ˆ„์  ๋ถˆ์šฉ์–ด", f"{len(stopwords_list)}๊ฐœ")
269
+ if all_count > 0:
270
+ if st.button("โ˜๏ธ ๋ˆ„์  ์›Œ๋“œํด๋ผ์šฐ๋“œ", key="btn_wc_all", use_container_width=True):
271
+ show_wordcloud_dialog("๋ˆ„์ ", wc_text_all)
272
+ st.caption("๐Ÿ”ฅ ์—ญ๋Œ€ ๋งŽ์ด ๋Œ€์ฒด๋œ ์žฌ๋ฃŒ")
273
+ if not all_targets.empty: st.bar_chart(all_targets, color="#FF6B6B", height=200)
274
+ else: st.info("๋ˆ„์  ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
275
+
276
+ with st.expander("๐Ÿ“‹ ์‹ ๊ณ ๋œ ๋ถˆ์šฉ์–ด ๋ชฉ๋ก ๋ณด๊ธฐ"):
277
+ if stopwords_list: st.dataframe(pd.DataFrame(stopwords_list, columns=["๋ถˆ์šฉ์–ด"]), use_container_width=True, hide_index=True)
278
+ else: st.info("์‹ ๊ณ ๋œ ๋ถˆ์šฉ์–ด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
279
+
280
+ st.divider()
281
+ with st.expander("๐Ÿ’ฌ ์ต๋ช… ๊ฒŒ์‹œํŒ (Beta)", expanded=True):
282
+ with st.form("board_form"):
283
+ st.text_input("๋‹‰๋„ค์ž„", placeholder="์ต๋ช…", key="board_nick_input")
284
+ st.text_area("๋‚ด์šฉ", placeholder="์ž์œ ๋กญ๊ฒŒ ์˜๊ฒฌ์„ ๋‚จ๊ฒจ์ฃผ์„ธ์š”", height=80, key="board_msg_input")
285
+ st.form_submit_button("๋“ฑ๋ก", on_click=handle_board_submission)
286
+
287
+ st.markdown("---")
288
+ messages = logic.get_board_messages()
289
+ if messages:
290
+ for m in messages:
291
+ st.markdown(f"**{m['nickname']}** <span style='color:grey; font-size:0.8em;'>({m['display_time']})</span>", unsafe_allow_html=True)
292
+ st.text(m['content'])
293
+ st.divider()
294
+ else: st.caption("์ฒซ ๋ฒˆ์งธ ๊ธ€์„ ๋‚จ๊ฒจ๋ณด์„ธ์š”!")
295
+
296
+ # -------------------------------------------------------------------------
297
+ # 4. ๋ฉ”์ธ UI (๊ธฐ์กด๊ณผ ๋™์ผ)
298
+ # -------------------------------------------------------------------------
299
+ col_main, _ = st.columns([0.9, 0.1])
300
+ with col_main:
301
+ if selected_mode == "๐Ÿ“š Ver.1 ๊ธฐ์กด ๋ ˆ์‹œํ”ผ DB ๊ฒ€์ƒ‰":
302
+ 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)
303
+ search_keyword = st.text_input("๐Ÿฝ๏ธ ์š”๋ฆฌ๋ช… ๊ฒ€์ƒ‰ (ํ‚ค์›Œ๋“œ ์ž…๋ ฅ ํ›„ ์—”ํ„ฐ)", placeholder="์˜ˆ: ๋œ์žฅ์ฐŒ๊ฐœ")
304
+ final_dish_name = None
305
+ if search_keyword:
306
+ exact_match = logic.df[logic.df['์š”๋ฆฌ๋ช…'] == search_keyword]
307
+ exact_name = exact_match['์š”๋ฆฌ๋ช…'].iloc[0] if not exact_match.empty else None
308
+ candidates = logic.df[logic.df['์š”๋ฆฌ๋ช…'].str.contains(search_keyword, na=False, case=False)]
309
+ if exact_name: candidates = candidates[candidates['์š”๋ฆฌ๋ช…'] != exact_name]
310
+ candidate_names = sorted(candidates['์š”๋ฆฌ๋ช…'].unique().tolist())[:30]
311
+ options = []
312
+ if exact_name: options.append(exact_name)
313
+ options.extend(candidate_names)
314
+ if not options: st.warning(f"๐Ÿ” '{search_keyword}'๊ฐ€ ํฌํ•จ๋œ ์š”๋ฆฌ๋ช…์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
315
+ else:
316
+ index_to_select = 0 if exact_name else None
317
+ label_msg = f"๐Ÿ”Ž '{search_keyword}' ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ({len(options)}๊ฐœ)"
318
+ if exact_name: label_msg += " - ์ •ํ™•ํ•œ ์š”๋ฆฌ๋ช…์ด ๋ฐœ๊ฒฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!"
319
+ selected_option = st.selectbox(label_msg, options, index=index_to_select)
320
+ final_dish_name = selected_option
321
+ if final_dish_name:
322
+ st.success(f"โœ… ์„ ํƒ๋œ ์š”๋ฆฌ: **{final_dish_name}**")
323
+ cands = logic.df[logic.df['์š”๋ฆฌ๋ช…'] == final_dish_name]
324
+ cands = cands.head(10).reset_index(drop=True)
325
+ if cands.empty: st.error("โŒ ๋ ˆ์‹œํ”ผ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
326
+ else:
327
+ st.divider()
328
+ options = {}
329
+ for _, r in cands.iterrows():
330
+ preview = ', '.join(r['์žฌ๋ฃŒํ† ํฐ'])
331
+ options[f"[{r['์š”๋ฆฌ๋ฐฉ๋ฒ•๋ณ„๋ช…']}] {r['์š”๋ฆฌ๋ช…']} (ID:{r['๋ ˆ์‹œํ”ผ์ผ๋ จ๋ฒˆํ˜ธ']}) - {preview}"] = r['๋ ˆ์‹œํ”ผ์ผ๋ จ๋ฒˆํ˜ธ']
332
+ selected_label = st.selectbox("๐Ÿ“œ ๋ถ„์„ํ•  ๋ ˆ์‹œํ”ผ๋ฅผ ์„ ํƒํ•˜์„ธ์š”", list(options.keys()))
333
+ recipe_id = options[selected_label]
334
+ c1, c2 = st.columns(2)
335
+ with c1: target_str = st.text_input("๐ŸŽฏ ๋ฐ”๊ฟ€ ์žฌ๋ฃŒ", placeholder="๋ผ์ง€๊ณ ๊ธฐ, ์–‘ํŒŒ")
336
+ with c2: stop_str = st.text_input("๐Ÿšซ ์ œ๊ฑฐํ•  ๋ฌธ๊ตฌ", placeholder="์•ฝ๊ฐ„, ์‹œํŒ์šฉ")
337
+ if target_str:
338
+ targets = [t.strip() for t in target_str.split(',') if t.strip()]
339
+ stops = [s.strip() for s in stop_str.split(',') if s.strip()]
340
+ current_recipe_row = logic.df[logic.df['๋ ˆ์‹œํ”ผ์ผ๋ จ๋ฒˆํ˜ธ'] == recipe_id].iloc[0]
341
+ recipe_ingredients = current_recipe_row['์žฌ๋ฃŒํ† ํฐ']
342
+ invalid_targets = [t for t in targets if t not in recipe_ingredients]
343
+ if not targets: st.warning("ํƒ€๊ฒŸ ์žฌ๋ฃŒ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.")
344
+ elif invalid_targets:
345
+ st.error(f"๐Ÿšจ ๋‹ค์Œ ์žฌ๋ฃŒ๋Š” ์„ ํƒํ•œ ๋ ˆ์‹œํ”ผ์— ์—†์Šต๋‹ˆ๋‹ค: {', '.join(invalid_targets)}")
346
+ st.info("๐Ÿ’ก ํŒ: ๋ ˆ์‹œํ”ผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์— ์žˆ๋Š” ์žฌ๋ฃŒ๋ช…์„ ์ •ํ™•ํžˆ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.")
347
+ else:
348
+ st.divider()
349
+ has_result = False
350
+ final_recs = []
351
+ if len(targets) == 1:
352
+ st.subheader("๐Ÿ”น ๋‹จ์ผ ์žฌ๋ฃŒ ๋Œ€์ฒด ์ถ”์ฒœ (DB ๊ธฐ๋ฐ˜)")
353
+ t = targets[0]
354
+ res = logic.substitute_single(recipe_id, t, stops, w_w2v, w_d2v, w_method, w_cat, topn=5)
355
+ st.markdown(f"**{t}** ๋Œ€์ฒด ๊ฒฐ๊ณผ")
356
+ if not res.empty:
357
+ has_result = True
358
+ final_recs = res['๋Œ€์ฒด์žฌ๋ฃŒ'].head(3).tolist()
359
+ d_df = res[['๋Œ€์ฒด์žฌ๋ฃŒ', '์ตœ์ข…์ ์ˆ˜', 'saving_score']].copy()
360
+ d_df['์˜ˆ์ƒ ์›๊ฐ€๋ณ€๋™'] = d_df['saving_score'].apply(lambda x: format_saving(x))
361
+ d_df = d_df[['๋Œ€์ฒด์žฌ๋ฃŒ', '์ตœ์ข…์ ์ˆ˜', '์˜ˆ์ƒ ์›๊ฐ€๋ณ€๋™']]
362
+ d_df.columns = ['์ถ”์ฒœ์žฌ๋ฃŒ', '์ ํ•ฉ๋„', '์˜ˆ์ƒ ์›๊ฐ€๋ณ€๋™']
363
+ st.dataframe(d_df.style.format("{:.1%}", subset=['์ ํ•ฉ๋„']).background_gradient(cmap='Greens', subset=['์ ํ•ฉ๋„']), use_container_width=True, hide_index=True)
364
+ else: st.warning("๊ฒฐ๊ณผ ์—†์Œ")
365
+ elif len(targets) > 1:
366
+ st.subheader("๐Ÿงฉ ์ตœ์ ์˜ ์žฌ๋ฃŒ ์กฐํ•ฉ (DB ๊ธฐ๋ฐ˜ ๋‹ค์ค‘ ๋Œ€์ฒด)")
367
+ multi_res = logic.substitute_multi(recipe_id, targets, stops, w_w2v, w_d2v, w_method, w_cat)
368
+ if multi_res:
369
+ has_result = True
370
+ final_recs = [", ".join(subs) for subs, score, saving in multi_res]
371
+ m_df = pd.DataFrame([(f"{', '.join(subs)}", score, format_saving(saving, True)) for subs, score, saving in multi_res], columns=['์ถ”์ฒœ ์กฐํ•ฉ', '์ข…ํ•ฉ ์ ์ˆ˜', '์˜ˆ์ƒ ์›๊ฐ€๋ณ€๋™ ํ•ฉ๊ณ„'])
372
+ st.dataframe(m_df.style.format("{:.1%}", subset=['์ข…ํ•ฉ ์ ์ˆ˜']).background_gradient(cmap='Blues', subset=['์ข…ํ•ฉ ์ ์ˆ˜']), use_container_width=True, hide_index=True)
373
+ else: st.info("์กฐํ•ฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
374
+ if has_result:
375
+ current_state = f"DB_{final_dish_name}_{target_str}_{stop_str}_{w_w2v}_{w_d2v}_{w_method}_{w_cat}_{final_recs}"
376
+ if 'last_log' not in st.session_state: st.session_state['last_log'] = ""
377
+ if st.session_state['last_log'] != current_state:
378
+ 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)
379
+ st.session_state['current_log_id'] = log_id
380
+ st.session_state['last_log'] = current_state
381
+ if 'current_log_id' in st.session_state and st.session_state['current_log_id']:
382
+ cl_id = st.session_state['current_log_id']
383
+ is_voted = cl_id in st.session_state['voted_logs']
384
+ st.write(""); b1, b2, _ = st.columns([0.2, 0.2, 0.6])
385
+ if is_voted: b1.success("โœ… ํ‰๊ฐ€ ์™„๋ฃŒ!"); b2.write("")
386
+ else:
387
+ 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("๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค!")))
388
+ 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("์˜๊ฒฌ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค.")))
389
+
390
+ elif selected_mode == "โœจ Ver.2 ๋‚˜๋งŒ์˜ ์žฌ๋ฃŒ ์ž…๋ ฅ (์ปค์Šคํ…€)":
391
+ 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)
392
+ st.markdown("##### ๐Ÿท๏ธ ์š”๋ฆฌ๋ช… ์ž…๋ ฅ (์ฐธ๊ณ ์šฉ)")
393
+ search_keyword_v2 = st.text_input("ํ‚ค์›Œ๋“œ ์ž…๋ ฅ ํ›„ ์—”ํ„ฐ (์˜ˆ: ๋ณถ์Œ๋ฐฅ) - ์„ ํƒ์‚ฌํ•ญ", key="v2_search")
394
+ custom_dish_name = search_keyword_v2
395
+ if search_keyword_v2:
396
+ exact_match_v2 = logic.df[logic.df['์š”๋ฆฌ๋ช…'] == search_keyword_v2]
397
+ exact_name_v2 = exact_match_v2['์š”๋ฆฌ๋ช…'].iloc[0] if not exact_match_v2.empty else None
398
+ candidates_v2 = logic.df[logic.df['์š”๋ฆฌ๋ช…'].str.contains(search_keyword_v2, na=False, case=False)]
399
+ if exact_name_v2: candidates_v2 = candidates_v2[candidates_v2['์š”๋ฆฌ๋ช…'] != exact_name_v2]
400
+ candidate_names_v2 = sorted(candidates_v2['์š”๋ฆฌ๋ช…'].unique().tolist())[:30]
401
+ options_v2 = []
402
+ if exact_name_v2: options_v2.append(exact_name_v2)
403
+ options_v2.append("(์ง์ ‘ ์ž…๋ ฅํ•œ ์ด๋ฆ„ ์‚ฌ์šฉ)")
404
+ options_v2.extend(candidate_names_v2)
405
+ if options_v2:
406
+ idx_v2 = 0 if exact_name_v2 else 0
407
+ label_v2 = f"๐Ÿ’ก ๊ด€๋ จ ์š”๋ฆฌ๋ช… ๋ฐœ๊ฒฌ ({len(options_v2)-1}๊ฐœ)"
408
+ if exact_name_v2: label_v2 += " - ์ •ํ™•ํ•œ ์š”๋ฆฌ๋ช… ๋ฐœ๊ฒฌ!"
409
+ sel_v2 = st.selectbox(label_v2, options_v2, index=idx_v2, key="v2_select")
410
+ if sel_v2 != "(์ง์ ‘ ์ž…๋ ฅํ•œ ์ด๋ฆ„ ์‚ฌ์šฉ)": custom_dish_name = sel_v2
411
+
412
+ st.write("")
413
+ context_str = st.text_area("๐Ÿ“ ์ „์ฒด ์žฌ๋ฃŒ ๋ฆฌ์ŠคํŠธ (์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„)", placeholder="์˜ˆ: ๋ฐฅ, ๊ณ„๋ž€, ๋Œ€ํŒŒ, ๊ฐ„์žฅ, ์ฐธ๊ธฐ๋ฆ„", height=100, key="v2_context")
414
+ if context_str:
415
+ context_ings_list = [ing.strip() for ing in context_str.split(',') if ing.strip()]
416
+ if not context_ings_list: st.warning("์žฌ๋ฃŒ๋ฅผ ํ•œ ๊ฐœ ์ด์ƒ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.")
417
+ else:
418
+ st.caption(f"์ธ์‹๋œ ์žฌ๋ฃŒ ({len(context_ings_list)}๊ฐœ): {', '.join(context_ings_list)}")
419
+ c1_c, c2_c = st.columns(2)
420
+ with c1_c: target_str_c = st.text_input("๐ŸŽฏ ๋ฐ”๊ฟ€ ์žฌ๋ฃŒ (์œ„ ๋ฆฌ์ŠคํŠธ ์ค‘)", placeholder="์˜ˆ: ๊ณ„๋ž€", key="v2_target")
421
+ with c2_c: stop_str_c = st.text_input("๐Ÿšซ ์ œ๊ฑฐํ•  ๋ฌธ๊ตฌ (์ž„์‹œ)", placeholder="์˜ˆ: ์•ฝ๊ฐ„", key="v2_stop")
422
+ if target_str_c:
423
+ targets_c = [t.strip() for t in target_str_c.split(',') if t.strip()]
424
+ stops_c = [s.strip() for s in stop_str_c.split(',') if s.strip()]
425
+ invalid_targets = [t for t in targets_c if t not in context_ings_list]
426
+ if invalid_targets: st.error(f"๐Ÿšจ ๋‹ค์Œ ์žฌ๋ฃŒ๋Š” ์ „์ฒด ๋ฆฌ์ŠคํŠธ์— ์—†์Šต๋‹ˆ๋‹ค: {', '.join(invalid_targets)}")
427
+ elif not targets_c: st.warning("๋ฐ”๊ฟ€ ์žฌ๋ฃŒ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.")
428
+ else:
429
+ st.divider()
430
+ has_result_c = False
431
+ final_recs_c = []
432
+ if len(targets_c) == 1:
433
+ st.subheader("๐Ÿ”น ๋‹จ์ผ ์žฌ๋ฃŒ ๋Œ€์ฒด ์ถ”์ฒœ (์ปค์Šคํ…€)")
434
+ t_c = targets_c[0]
435
+ res_c = logic.substitute_single_custom(t_c, context_ings_list, stops_c, w_w2v, w_d2v, excluded_ings=excluded_ingredients, topn=5)
436
+ st.markdown(f"**{t_c}** ๋Œ€์ฒด ๊ฒฐ๊ณผ")
437
+ if not res_c.empty:
438
+ has_result_c = True
439
+ final_recs_c = res_c['๋Œ€์ฒด์žฌ๋ฃŒ'].head(3).tolist()
440
+ d_df_c = res_c[['๋Œ€์ฒด์žฌ๋ฃŒ', '์ตœ์ข…์ ์ˆ˜', 'saving_score']].copy()
441
+ d_df_c['์˜ˆ์ƒ ์›๊ฐ€๋ณ€๋™'] = d_df_c['saving_score'].apply(lambda x: format_saving(x))
442
+ d_df_c = d_df_c[['๋Œ€์ฒด์žฌ๋ฃŒ', '์ตœ์ข…์ ์ˆ˜', '์˜ˆ์ƒ ์›๊ฐ€๋ณ€๋™']]
443
+ d_df_c.columns = ['์ถ”์ฒœ์žฌ๋ฃŒ', '์ ํ•ฉ๋„', '์˜ˆ์ƒ ์›๊ฐ€๋ณ€๋™']
444
+ st.dataframe(d_df_c.style.format("{:.1%}", subset=['์ ํ•ฉ๋„']).background_gradient(cmap='Greens', subset=['์ ํ•ฉ๋„']), use_container_width=True, hide_index=True)
445
+ else: st.warning("๊ฒฐ๊ณผ ์—†์Œ")
446
+ elif len(targets_c) > 1:
447
+ st.subheader("๐Ÿงฉ ์ตœ์ ์˜ ์žฌ๋ฃŒ ์กฐํ•ฉ (์ปค์Šคํ…€ ๋‹ค์ค‘ ๋Œ€์ฒด)")
448
+ multi_res_c = logic.substitute_multi_custom(targets_c, context_ings_list, stops_c, w_w2v, w_d2v, excluded_ings=excluded_ingredients)
449
+ if multi_res_c:
450
+ has_result_c = True
451
+ final_recs_c = [", ".join(subs) for subs, score, saving in multi_res_c]
452
+ m_df_c = pd.DataFrame([(f"{', '.join(subs)}", score, format_saving(saving, True)) for subs, score, saving in multi_res_c], columns=['์ถ”์ฒœ ์กฐํ•ฉ', '์ข…ํ•ฉ ์ ์ˆ˜', '์˜ˆ์ƒ ์›๊ฐ€๋ณ€๋™ ํ•ฉ๊ณ„'])
453
+ st.dataframe(m_df_c.style.format("{:.1%}", subset=['์ข…ํ•ฉ ์ ์ˆ˜']).background_gradient(cmap='Blues', subset=['์ข…ํ•ฉ ์ ์ˆ˜']), use_container_width=True, hide_index=True)
454
+ else: st.info("์กฐํ•ฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
455
+ if has_result_c:
456
+ current_state_c = f"Custom_{custom_dish_name}_{target_str_c}_{stop_str_c}_{w_w2v}_{w_d2v}_{final_recs_c}"
457
+ if 'last_log_c' not in st.session_state: st.session_state['last_log_c'] = ""
458
+ if st.session_state['last_log_c'] != current_state_c:
459
+ 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)
460
+ st.session_state['current_log_id_c'] = log_id_c
461
+ st.session_state['last_log_c'] = current_state_c
462
+ if 'current_log_id_c' in st.session_state and st.session_state['current_log_id_c']:
463
+ cl_id_c = st.session_state['current_log_id_c']
464
+ is_voted_c = cl_id_c in st.session_state['voted_logs']
465
+ st.write(""); b1_c, b2_c, _ = st.columns([0.2, 0.2, 0.6])
466
+ if is_voted_c: b1_c.success("โœ… ํ‰๊ฐ€ ์™„๋ฃŒ!"); b2_c.write("")
467
+ else:
468
+ 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("๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค!")))
469
+ 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("์˜๊ฒฌ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค.")))
470
+ else: st.info("๐Ÿ‘† ์ „์ฒด ์žฌ๋ฃŒ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋จผ์ € ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.")
471
+
472
+ # -------------------------------------------------------------------------
473
+ # 5. ํ•˜๋‹จ ํ”ผ๋“œ๋ฐฑ ๋ฐ ๋ถˆ์šฉ์–ด ์‹ ๊ณ  ์˜์—ญ
474
+ # -------------------------------------------------------------------------
475
+ st.divider()
476
+ col_feedback, col_stopword = st.columns(2)
477
+
478
+ with col_feedback:
479
+ st.subheader("๐Ÿ“ข ์„œ๋น„์Šค ์˜๊ฒฌ ๋ณด๋‚ด๊ธฐ")
480
+ with st.form("feedback_form"):
481
+ text = st.text_area("๊ฐœ์„ ํ•  ์ ์ด๋‚˜ ๋ฒ„๊ทธ๊ฐ€ ์žˆ๋‹ค๋ฉด ์•Œ๋ ค์ฃผ์„ธ์š”!", height=100, key="feedback_input_field")
482
+ st.form_submit_button("์˜๊ฒฌ ๋ณด๋‚ด๊ธฐ", use_container_width=True, on_click=handle_feedback_submission)
483
+
484
+ with col_stopword:
485
+ st.subheader("๐Ÿšซ ๋ถˆ์šฉ์–ด(์ด์ƒํ•œ ๋‹จ์–ด) ์‹ ๊ณ ํ•˜๊ธฐ")
486
+ st.caption(
487
+ "์ถ”์ฒœ ๊ฒฐ๊ณผ์— ์ด์ƒํ•œ ๋‹จ์–ด๊ฐ€ ์žˆ๋‚˜์š”? ์‹ ๊ณ ํ•ด์ฃผ์‹œ๋ฉด ๋‹ค์Œ๋ถ€ํ„ฐ ์ œ์™ธ๋ฉ๋‹ˆ๋‹ค.",
488
+ help="ํ˜„์žฌ ํ•™์Šต ๋ฐ์ดํ„ฐ์— ํฌํ•จ๋œ ๋ถˆ์šฉ์–ด๊ฐ€ ๋„ˆ๋ฌด ๋งŽ์•„ ์ผ์ผ์ด ์ˆ˜์ž‘์—…์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์–ด๋ ต์Šต๋‹ˆ๋‹ค. ๐Ÿ˜ฅ ์—ฌ๋Ÿฌ๋ถ„์˜ ์‹ ๊ณ ๊ฐ€ ๋ชจ์ด๋ฉด ๋ฐ์ดํ„ฐ์˜ ํ’ˆ์งˆ์ด ๋†’์•„์ง€๊ณ  ์ถ”์ฒœ ๊ฒฐ๊ณผ๋„ ๋” ์ •ํ™•ํ•ด์ง‘๋‹ˆ๋‹ค. ์†Œ์ค‘ํ•œ ๊ธฐ์—ฌ ๋ถ€ํƒ๋“œ๋ฆฝ๋‹ˆ๋‹ค! ๐Ÿ™"
489
+ )
490
+ st.info("๐Ÿ’ก Tip: '๊ฐ„์žฅor์ง„๊ฐ„์žฅ' ๊ฐ™์€ ๊ฒฝ์šฐ 'or'๋ฅผ ์‹ ๊ณ ํ•˜๋ฉด '๊ฐ„์žฅ์ง„๊ฐ„์žฅ'์œผ๋กœ ํ•ฉ์ณ์ ธ ์ถ”์ฒœ์—์„œ ์ œ์™ธ๋ฉ๋‹ˆ๋‹ค.")
491
+
492
+ with st.form("stopword_form"):
493
+ st.text_input("์‹ ๊ณ ํ•  ๋‹จ์–ด ์ž…๋ ฅ (์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„)", placeholder="์˜ˆ: ๋ฉดํฌ, ํ™ฉ์„์–ด์ “, ํ…ƒ๋ฐญ", key="stopword_input_field")
494
+ st.form_submit_button("์‹ ๊ณ ํ•˜๊ธฐ", use_container_width=True, on_click=handle_stopword_submission)
src/logic.py ADDED
@@ -0,0 +1,639 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # logic.py
2
+ import streamlit as st
3
+ import pandas as pd
4
+ import numpy as np
5
+ from gensim.models import Word2Vec, Doc2Vec
6
+ from ast import literal_eval
7
+ import pickle
8
+ from datetime import datetime, timedelta, timezone
9
+ from supabase import create_client
10
+ import re
11
+ from collections import Counter
12
+
13
+ # ==========================================
14
+ # 0. ํ™˜๊ฒฝ ์„ค์ • ๋ฐ ๊ทœ์น™ ์ •์˜
15
+ # ==========================================
16
+ PRICE_KEYWORD_RULES = [
17
+ (5, ['์†Œ๊ณ ๊ธฐ', 'ํ•œ์šฐ', '์ฑ„๋', '๋“ฑ์‹ฌ', '์•ˆ์‹ฌ', '๊ฐˆ๋น„์‚ด', '์ „๋ณต', '์žฅ์–ด']),
18
+ (4, ['๋ผ์ง€', '์‚ผ๊ฒน', '๋ชฉ์‚ด', '์•ž๋‹ค๋ฆฌ', '๋’ท๋‹ค๋ฆฌ', '๊ฐˆ๋น„', '์˜ค๋ฆฌ', '๋‚™์ง€', '์˜ค์ง•์–ด', '์ƒˆ์šฐ', '๋ช…๋ž€']),
19
+ (3, ['๋‹ญ', '์น˜ํ‚จ', 'ํ–„', '์†Œ์‹œ์ง€', '๋ฒ ์ด์ปจ', '์ŠคํŒธ', '์ฐธ์น˜', '๋™์›', '์–ด๋ฌต', '๋ง›์‚ด', '๋ฒ„์„ฏ', '์น˜์ฆˆ']),
20
+ (2, ['๋‘๋ถ€', '์ˆœ๋‘๋ถ€', '์ฝฉ๋‚˜๋ฌผ', '์ˆ™์ฃผ', '๊น€์น˜', '๋ฌด', '๊ฐ์ž', '๊ณ ๊ตฌ๋งˆ', '๋‹น๊ทผ', 'ํ˜ธ๋ฐ•']),
21
+ (1, ['์–‘ํŒŒ', '๋Œ€ํŒŒ', '์ชฝํŒŒ', '์‹คํŒŒ', '๋งˆ๋Š˜', '๊ณ ์ถ”', '๋ฌผ', '์†Œ๊ธˆ', '์„คํƒ•', '๊ฐ„์žฅ', '์†Œ์Šค', '์–‘๋…', '์œก์ˆ˜'])
22
+ ]
23
+ PRICE_RULE_EXCEPTIONS = ['๋ผ์ง€๊ฐ์ž', '๋‹ญ์˜์žฅํ’€', '์ƒˆ์šฐ์ “', '๋ฉธ์น˜์•ก์ “', '๋‹ค์‹œ๋‹ค']
24
+
25
+ # ==========================================
26
+ # 1. Supabase DB ์—ฐ๋™ ๋ฐ ๋ฐ์ดํ„ฐ ์ €์žฅ/๋กœ๋“œ
27
+ # ==========================================
28
+ @st.cache_resource
29
+ def init_supabase():
30
+ try:
31
+ url = None
32
+ key = None
33
+
34
+ # 1. Try Streamlit secrets
35
+ try:
36
+ if hasattr(st, "secrets") and "supabase" in st.secrets:
37
+ url = st.secrets["supabase"]["url"]
38
+ key = st.secrets["supabase"]["key"]
39
+ except:
40
+ pass
41
+
42
+ # 2. Add fallback to OS environment variables
43
+ if not url or not key:
44
+ url = os.environ.get("SUPABASE_URL")
45
+ key = os.environ.get("SUPABASE_KEY")
46
+
47
+ if not url or not key:
48
+ # ๋กœ์ปฌ ๊ฐœ๋ฐœ ์ค‘ secrets ์—†์ด ์‹คํ–‰๋  ๊ฒฝ์šฐ๋ฅผ ๋Œ€๋น„ํ•ด ์Šคํ‚ตํ•˜๊ฑฐ๋‚˜ ์—๋Ÿฌ ์ฒ˜๋ฆฌ
49
+ # API ์„œ๋ฒ„์—์„œ๋Š” ํ•„์ˆ˜์ด๋ฏ€๋กœ ๋กœ๊ทธ ๋‚จ๊น€
50
+ return None
51
+
52
+ return create_client(url, key)
53
+ except Exception as e:
54
+ print(f"Supabase ์—ฐ๊ฒฐ ๊ฒฝ๊ณ : {e}")
55
+ return None
56
+
57
+ def get_kst_now_iso():
58
+ kst_timezone = timezone(timedelta(hours=9))
59
+ now_kst = datetime.now(kst_timezone)
60
+ return now_kst.isoformat()
61
+
62
+ @st.cache_data(ttl=300)
63
+ def load_global_stopwords():
64
+ try:
65
+ supabase = init_supabase()
66
+ response = supabase.table("stopwords").select("word").order("created_at", desc=True).execute()
67
+ if response.data:
68
+ return [item['word'] for item in response.data]
69
+ return []
70
+ except Exception as e:
71
+ print(f"๋ถˆ์šฉ์–ด ๋กœ๋“œ ์‹คํŒจ: {e}")
72
+ return []
73
+
74
+ @st.cache_data(ttl=600)
75
+ def get_usage_stats(timeframe='today'):
76
+ try:
77
+ supabase = init_supabase()
78
+ query = supabase.table("usage_log").select("dish, target")
79
+
80
+ if timeframe == 'today':
81
+ kst = timezone(timedelta(hours=9))
82
+ now_kst = datetime.now(kst)
83
+ today_start = now_kst.replace(hour=0, minute=0, second=0, microsecond=0)
84
+ tomorrow_start = today_start + timedelta(days=1)
85
+ query = query.gte("created_at", today_start.isoformat()).lt("created_at", tomorrow_start.isoformat())
86
+
87
+ response = query.execute()
88
+ data = response.data
89
+
90
+ count = len(data)
91
+ top_dishes = pd.Series(dtype=int)
92
+ top_targets = pd.Series(dtype=int)
93
+
94
+ if count > 0:
95
+ df_log = pd.DataFrame(data)
96
+ df_log['clean_dish'] = df_log['dish'].astype(str).str.replace(r'\[Custom\]', '', regex=True).str.strip()
97
+ top_dishes = df_log[df_log['clean_dish'] != '']['clean_dish'].value_counts().head(5)
98
+
99
+ all_targets = []
100
+ for t in df_log['target']:
101
+ if t:
102
+ all_targets.extend([x.strip() for x in str(t).split(',') if x.strip()])
103
+ top_targets = pd.Series(all_targets).value_counts().head(5)
104
+
105
+ return count, top_dishes, top_targets
106
+
107
+ except Exception as e:
108
+ print(f"ํ†ต๊ณ„ ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์‹คํŒจ ({timeframe}): {e}")
109
+ return 0, pd.Series(dtype=int), pd.Series(dtype=int)
110
+
111
+ @st.cache_data(ttl=600)
112
+ def get_wordcloud_text(timeframe='today'):
113
+ try:
114
+ supabase = init_supabase()
115
+ query = supabase.table("usage_log").select("target")
116
+ if timeframe == 'today':
117
+ kst = timezone(timedelta(hours=9))
118
+ now_kst = datetime.now(kst)
119
+ today_start = now_kst.replace(hour=0, minute=0, second=0, microsecond=0)
120
+ tomorrow_start = today_start + timedelta(days=1)
121
+ query = query.gte("created_at", today_start.isoformat()).lt("created_at", tomorrow_start.isoformat())
122
+
123
+ response = query.execute()
124
+ data = response.data
125
+ all_targets = []
126
+ if data:
127
+ for item in data:
128
+ if item['target']:
129
+ all_targets.extend([x.strip() for x in str(item['target']).split(',') if x.strip()])
130
+ return " ".join(all_targets)
131
+ except Exception as e:
132
+ print(f"์›Œ๋“œํด๋ผ์šฐ๋“œ ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์‹คํŒจ: {e}")
133
+ return ""
134
+
135
+ def save_stopwords_to_db(words_string):
136
+ words = [w.strip() for w in words_string.split(',') if w.strip()]
137
+ if not words: return False, "์ €์žฅํ•  ๋‹จ์–ด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."
138
+ supabase = init_supabase()
139
+ success_count, duplicate_count, fail_count = 0, 0, 0
140
+ for word in words:
141
+ try:
142
+ supabase.table("stopwords").insert({"word": word}).execute()
143
+ success_count += 1
144
+ except Exception as e:
145
+ if 'duplicate' in str(e).lower(): duplicate_count += 1
146
+ else: fail_count += 1
147
+ if success_count > 0: st.cache_data.clear()
148
+ msg_parts = []
149
+ if success_count > 0: msg_parts.append(f"โœ… {success_count}๊ฐœ ์ €์žฅ")
150
+ if duplicate_count > 0: msg_parts.append(f"โš ๏ธ {duplicate_count}๊ฐœ ์ค‘๋ณต")
151
+ if fail_count > 0: msg_parts.append(f"โŒ {fail_count}๊ฐœ ์‹คํŒจ")
152
+ return success_count > 0, ", ".join(msg_parts)
153
+
154
+ @st.cache_data(ttl=60)
155
+ def get_board_messages():
156
+ try:
157
+ supabase = init_supabase()
158
+ response = supabase.table("board").select("*").order("created_at", desc=True).limit(50).execute()
159
+ if response.data:
160
+ for item in response.data:
161
+ dt = datetime.fromisoformat(item['created_at'])
162
+ dt_kst = dt + timedelta(hours=9)
163
+ item['display_time'] = dt_kst.strftime("%m/%d %H:%M")
164
+ return response.data
165
+ return []
166
+ except Exception as e:
167
+ print(f"๊ฒŒ์‹œํŒ ๋กœ๋“œ ์‹คํŒจ: {e}")
168
+ return []
169
+
170
+ def save_board_message(nickname, content):
171
+ if not nickname or not content: return False
172
+ try:
173
+ supabase = init_supabase()
174
+ supabase.table("board").insert({"nickname": nickname, "content": content}).execute()
175
+ st.cache_data.clear()
176
+ return True
177
+ except Exception as e:
178
+ print(f"๊ฒŒ์‹œํŒ ์ €์žฅ ์‹คํŒจ: {e}")
179
+ return False
180
+
181
+ def save_feedback_to_db(feedback_text):
182
+ try:
183
+ supabase = init_supabase()
184
+ supabase.table("feedback").insert({"content": feedback_text, "created_at": get_kst_now_iso()}).execute()
185
+ return True
186
+ except Exception as e:
187
+ print(f"ํ”ผ๋“œ๋ฐฑ ์ €์žฅ ์—๋Ÿฌ: {e}")
188
+ return False
189
+
190
+ def save_log_to_db(dish, target, stops, w1, w2, w3, w4, rec_list=None, is_custom=False):
191
+ try:
192
+ supabase = init_supabase()
193
+ r1 = rec_list[0] if rec_list and len(rec_list) > 0 else None
194
+ r2 = rec_list[1] if rec_list and len(rec_list) > 1 else None
195
+ r3 = rec_list[2] if rec_list and len(rec_list) > 2 else None
196
+ dish_name_to_save = f"[Custom] {dish}" if is_custom else dish
197
+ data = {
198
+ "dish": dish_name_to_save, "target": target, "stops": ", ".join(stops) if stops else "์—†์Œ",
199
+ "w_w2v": w1, "w_d2v": w2, "w_method": w3, "w_cat": w4, "rec_1": r1, "rec_2": r2, "rec_3": r3,
200
+ "created_at": get_kst_now_iso()
201
+ }
202
+ response = supabase.table("usage_log").insert(data).execute()
203
+ if response.data: return response.data[0]['id']
204
+ return None
205
+ except Exception as e:
206
+ print(f"๋กœ๊ทธ ์ €์žฅ ์—๋Ÿฌ: {e}")
207
+ return None
208
+
209
+ def update_feedback_in_db(log_id, status):
210
+ try:
211
+ supabase = init_supabase()
212
+ if log_id:
213
+ supabase.table("usage_log").update({"satisfaction": status}).eq("id", log_id).execute()
214
+ return True
215
+ return False
216
+ except Exception as e:
217
+ print(f"๋งŒ์กฑ๋„ ์—…๋ฐ์ดํŠธ ์—๋Ÿฌ: {e}")
218
+ return False
219
+
220
+ # ==========================================
221
+ # 2. ๋ฐ์ดํ„ฐ ๋ฐ ๋ชจ๋ธ ๋กœ๋“œ
222
+ # ==========================================
223
+ # ==========================================
224
+ # 2. ๋ฐ์ดํ„ฐ ๋ฐ ๋ชจ๋ธ ๋กœ๋“œ (Lazy Loading ์ ์šฉ)
225
+ # ==========================================
226
+ w2v_model = None
227
+ d2v_model = None
228
+ df = None
229
+ stats = None
230
+ price_map = {}
231
+ global_stopwords_set = set()
232
+ all_ingredients_set = set()
233
+ method_map = {}
234
+ recipes_by_ingredient = {}
235
+ ing_method_counts = {}
236
+ ing_cat_counts = {}
237
+ total_method_counts = {}
238
+ total_cat_counts = {}
239
+ TOTAL_RECIPES = 0
240
+
241
+ def load_resources():
242
+ global w2v_model, d2v_model, df, stats, price_map, global_stopwords_set, all_ingredients_set
243
+ global method_map, recipes_by_ingredient, ing_method_counts, ing_cat_counts, total_method_counts, total_cat_counts, TOTAL_RECIPES
244
+
245
+ print("Loading resources... (This may take a while)")
246
+ # mmap='r' ์˜ต์…˜์œผ๋กœ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ตœ์†Œํ™” (๋””์Šคํฌ์—์„œ ์ง์ ‘ ์ฝ์Œ)
247
+ w2v_model = Word2Vec.load("models/w2v.model", mmap='r')
248
+ d2v_model = Doc2Vec.load("models/d2v.model", mmap='r')
249
+ df_temp = pd.read_csv("data/final_recipe_data.csv")
250
+ df_temp['์žฌ๋ฃŒํ† ํฐ'] = df_temp['์žฌ๋ฃŒํ† ํฐ'].apply(literal_eval)
251
+ df = df_temp # Assign to global
252
+
253
+ with open("data/stats.pkl", "rb") as f:
254
+ stats = pickle.load(f)
255
+
256
+ try:
257
+ price_df = pd.read_csv("data/price_rank.csv", encoding='utf-8-sig')
258
+ price_df.columns = price_df.columns.str.strip()
259
+ price_map = dict(zip(price_df['ingredient'], price_df['rank']))
260
+ except:
261
+ price_map = {}
262
+
263
+ global_stopwords_set = set()
264
+ all_ingredients_set = set()
265
+ for ings in df['์žฌ๋ฃŒํ† ํฐ']:
266
+ all_ingredients_set.update(ings)
267
+
268
+ # Stats unpacking
269
+ method_map = stats["method_map"]
270
+ recipes_by_ingredient = stats["recipes_by_ingredient"]
271
+ ing_method_counts = stats["ing_method_counts"]
272
+ ing_cat_counts = stats["ing_cat_counts"]
273
+ total_method_counts = stats["total_method_counts"]
274
+ total_cat_counts = stats["total_cat_counts"]
275
+ TOTAL_RECIPES = stats["TOTAL_RECIPES"]
276
+ print("Resources loaded successfully!")
277
+
278
+ def ensure_initialized():
279
+ if df is None:
280
+ load_resources()
281
+
282
+ # ๊ธฐ์กด ์ฆ‰์‹œ ์‹คํ–‰ ์ฝ”๋“œ ์ œ๊ฑฐ
283
+ # w2v_model, d2v_model, ... = load_resources()
284
+
285
+ # ==========================================
286
+ # 3. ํ•ต์‹ฌ ๊ณ„์‚ฐ ๋กœ์ง
287
+ # ==========================================
288
+ def cos_sim(vec_a, vec_b):
289
+ norm = (np.linalg.norm(vec_a) * np.linalg.norm(vec_b) + 1e-9)
290
+ return max(0.0, float(np.dot(vec_a, vec_b) / norm))
291
+
292
+ def get_stat_score(ingredient, target_key, ing_count_dict, total_count_dict, total_n, min_count=5):
293
+ cnts = ing_count_dict.get(ingredient)
294
+ if not cnts: return 0.0
295
+ ing_target_count = cnts[target_key]
296
+ ing_total_count = sum(cnts.values())
297
+ if ing_total_count < min_count: return 0.0
298
+ prob_ing_context = ing_target_count / ing_total_count
299
+ baseline_prob = total_count_dict[target_key] / total_n
300
+ if baseline_prob == 0: return 0.0
301
+ return prob_ing_context / baseline_prob
302
+
303
+ def get_estimated_price_rank(ing_name, price_map):
304
+ if ing_name in price_map: return price_map[ing_name]
305
+ if any(exp in ing_name for exp in PRICE_RULE_EXCEPTIONS): return 3
306
+ for rank, keywords in PRICE_KEYWORD_RULES:
307
+ if any(kw in ing_name for kw in keywords): return rank
308
+ return 3
309
+
310
+ # ==========================================
311
+ # 4. ๋Œ€์ฒด ์ถ”์ฒœ ์•Œ๊ณ ๋ฆฌ์ฆ˜ (DB ๊ธฐ๋ฐ˜)
312
+ # ==========================================
313
+ def substitute_single(recipe_id, target_ing, user_stopwords, w_w2v, w_d2v, w_method, w_cat, topn=10):
314
+ row = df[df['๋ ˆ์‹œํ”ผ์ผ๋ จ๋ฒˆํ˜ธ'] == recipe_id].iloc[0]
315
+ current_method = row['์š”๋ฆฌ๋ฐฉ๋ฒ•๋ณ„๋ช…']
316
+ current_cat = row['์š”๋ฆฌ์ข…๋ฅ˜๋ณ„๋ช…_์„ธ๋ถ„ํ™”']
317
+ context_ings = row['์žฌ๋ฃŒํ† ํฐ']
318
+ tag = f"recipe_{recipe_id}"
319
+ if target_ing not in w2v_model.wv: return pd.DataFrame()
320
+ total_weight = w_w2v + w_d2v + w_method + w_cat
321
+ if total_weight == 0: total_weight = 1.0
322
+ vec_recipe = None
323
+ if w_d2v > 0 and tag in d2v_model.dv: vec_recipe = d2v_model.dv[tag]
324
+ target_rank = get_estimated_price_rank(target_ing, price_map)
325
+ candidates_raw = w2v_model.wv.most_similar(target_ing, topn=50)
326
+ temp_results = []
327
+ seen_candidates = set()
328
+
329
+ # [์ˆ˜์ •] ์‹ค์‹œ๊ฐ„ ๋กœ๋“œ
330
+ global_stopwords_set = set(load_global_stopwords())
331
+ final_stopwords = set(user_stopwords) | global_stopwords_set
332
+
333
+ for cand, score_w2v in candidates_raw:
334
+ clean_cand = cand
335
+ if final_stopwords:
336
+ for stop in final_stopwords: clean_cand = clean_cand.replace(stop, "")
337
+ clean_cand = clean_cand.strip()
338
+
339
+ if not clean_cand: continue
340
+ if clean_cand in final_stopwords: continue
341
+
342
+ if clean_cand in context_ings: continue
343
+ if clean_cand == target_ing: continue
344
+ if clean_cand not in w2v_model.wv: continue
345
+ if clean_cand in seen_candidates: continue
346
+ seen_candidates.add(clean_cand)
347
+ real_score_w2v = w2v_model.wv.similarity(target_ing, clean_cand)
348
+ s_w2v = max(0.0, real_score_w2v)
349
+ if s_w2v < 0.35: continue
350
+ s_d2v = 0.0
351
+ if w_d2v > 0 and vec_recipe is not None:
352
+ rid_list = recipes_by_ingredient.get(clean_cand, [])
353
+ same_method_ids = [r for r in rid_list if method_map.get(r) == current_method]
354
+ if len(same_method_ids) > 20:
355
+ np.random.seed(42)
356
+ same_method_ids = np.random.choice(same_method_ids, 20, replace=False)
357
+ if same_method_ids is not None and len(same_method_ids) > 0:
358
+ sims = []
359
+ for r in same_method_ids:
360
+ rt = f"recipe_{r}"
361
+ if rt in d2v_model.dv: sims.append(cos_sim(vec_recipe, d2v_model.dv[rt]))
362
+ if sims: s_d2v = np.mean(sims)
363
+ s_method = 0.0 if w_method <= 0 else get_stat_score(clean_cand, current_method, ing_method_counts, total_method_counts, TOTAL_RECIPES)
364
+ s_cat = 0.0 if w_cat <= 0 else get_stat_score(clean_cand, current_cat, ing_cat_counts, total_cat_counts, TOTAL_RECIPES)
365
+ cand_rank = get_estimated_price_rank(clean_cand, price_map)
366
+ saving_score = target_rank - cand_rank
367
+ temp_results.append({"๋Œ€์ฒด์žฌ๋ฃŒ": clean_cand, "raw_W2V": s_w2v, "raw_D2V": s_d2v, "raw_Method": s_method, "raw_Category": s_cat, "saving_score": saving_score})
368
+ if not temp_results: return pd.DataFrame()
369
+ df_res = pd.DataFrame(temp_results)
370
+ cols = ["raw_W2V", "raw_D2V", "raw_Method", "raw_Category"]
371
+ norm_cols = ["W2V", "D2V", "Method", "Category"]
372
+ for raw_col, norm_col in zip(cols, norm_cols):
373
+ min_val = df_res[raw_col].min()
374
+ max_val = df_res[raw_col].max()
375
+ if max_val - min_val == 0: df_res[norm_col] = 0.5
376
+ else: df_res[norm_col] = (df_res[raw_col] - min_val) / (max_val - min_val)
377
+ df_res["์ตœ์ข…์ ์ˆ˜"] = ((df_res["W2V"]*w_w2v) + (df_res["D2V"]*w_d2v) + (df_res["Method"]*w_method) + (df_res["Category"]*w_cat)) / total_weight
378
+ return df_res.sort_values("์ตœ์ข…์ ์ˆ˜", ascending=False).head(topn).reset_index(drop=True)
379
+
380
+ def substitute_multi(recipe_id, targets, user_stopwords, w_w2v, w_d2v, w_method, w_cat, beam_width=3, result_topn=3):
381
+ row = df[df['๋ ˆ์‹œํ”ผ์ผ๋ จ๋ฒˆํ˜ธ'] == recipe_id].iloc[0]
382
+ current_method = row['์š”๋ฆฌ๋ฐฉ๋ฒ•๋ณ„๋ช…']
383
+ current_cat = row['์š”๋ฆฌ์ข…๋ฅ˜๋ณ„๋ช…_์„ธ๋ถ„ํ™”']
384
+ initial_context = row['์žฌ๋ฃŒํ† ํฐ']
385
+ tag = f"recipe_{recipe_id}"
386
+ vec_recipe = None
387
+ if w_d2v > 0 and tag in d2v_model.dv: vec_recipe = d2v_model.dv[tag]
388
+ total_weight = w_w2v + w_d2v + w_method + w_cat
389
+ if total_weight == 0: total_weight = 1.0
390
+ target_ranks_sum = 0
391
+ for t in targets: target_ranks_sum += get_estimated_price_rank(t, price_map)
392
+
393
+ # [์ˆ˜์ •] ์‹ค์‹œ๊ฐ„ ๋กœ๋“œ
394
+ global_stopwords_set = set(load_global_stopwords())
395
+ final_stopwords = set(user_stopwords) | global_stopwords_set
396
+
397
+ beam = [(0.0, [], initial_context)]
398
+ for target_ing in targets:
399
+ next_beam = []
400
+ if target_ing not in w2v_model.wv:
401
+ for score, subs, ctx in beam: next_beam.append((score, subs + [target_ing], ctx))
402
+ beam = next_beam
403
+ continue
404
+ for path_score, path_subs, path_ctx in beam:
405
+ current_ctx_ing = [x for x in path_ctx if x != target_ing]
406
+ candidates = w2v_model.wv.most_similar(target_ing, topn=30)
407
+ temp_candidates = []
408
+ seen_candidates = set()
409
+ for cand, _ in candidates:
410
+ clean_cand = cand
411
+ if final_stopwords:
412
+ for stop in final_stopwords: clean_cand = clean_cand.replace(stop, "")
413
+ clean_cand = clean_cand.strip()
414
+
415
+ if not clean_cand: continue
416
+ if clean_cand in final_stopwords: continue
417
+
418
+ if clean_cand in current_ctx_ing or clean_cand in path_subs: continue
419
+ if clean_cand == target_ing: continue
420
+ if clean_cand not in w2v_model.wv: continue
421
+ if clean_cand in seen_candidates: continue
422
+ seen_candidates.add(clean_cand)
423
+ sim_orig = w2v_model.wv.similarity(target_ing, clean_cand)
424
+ sim_orig = max(0.0, sim_orig)
425
+ if sim_orig < 0.3: continue
426
+ harmony_scores = [w2v_model.wv.similarity(clean_cand, c) for c in current_ctx_ing if c in w2v_model.wv]
427
+ sim_harmony = np.mean(harmony_scores) if harmony_scores else 0.0
428
+ s_w2v = 0.5 * sim_orig + 0.5 * max(0.0, sim_harmony)
429
+ s_d2v = 0.0
430
+ if vec_recipe is not None:
431
+ rid_list = recipes_by_ingredient.get(clean_cand, [])
432
+ same_method_ids = [r for r in rid_list if method_map.get(r) == current_method]
433
+ if len(same_method_ids) > 10:
434
+ np.random.seed(42)
435
+ same_method_ids = np.random.choice(same_method_ids, 10, replace=False)
436
+ if same_method_ids is not None and len(same_method_ids) > 0:
437
+ sims = []
438
+ for r in same_method_ids:
439
+ rt = f"recipe_{r}"
440
+ if rt in d2v_model.dv: sims.append(cos_sim(vec_recipe, d2v_model.dv[rt]))
441
+ if sims: s_d2v = np.mean(sims)
442
+ s_method = 0.0 if w_method <= 0 else get_stat_score(clean_cand, current_method, ing_method_counts, total_method_counts, TOTAL_RECIPES)
443
+ s_cat = 0.0 if w_cat <= 0 else get_stat_score(clean_cand, current_cat, ing_cat_counts, total_cat_counts, TOTAL_RECIPES)
444
+ temp_candidates.append({"cand": clean_cand, "raw_w2v": s_w2v, "raw_d2v": s_d2v, "raw_method": s_method, "raw_cat": s_cat})
445
+ if not temp_candidates: continue
446
+ df_temp = pd.DataFrame(temp_candidates)
447
+ cols = ["raw_w2v", "raw_d2v", "raw_method", "raw_cat"]
448
+ for col in cols:
449
+ min_val = df_temp[col].min()
450
+ max_val = df_temp[col].max()
451
+ if max_val - min_val == 0: df_temp[col + "_norm"] = 0.5
452
+ else: df_temp[col + "_norm"] = (df_temp[col] - min_val) / (max_val - min_val)
453
+ for _, r in df_temp.iterrows():
454
+ weighted_sum = ((r["raw_w2v_norm"]*w_w2v) + (r["raw_d2v_norm"]*w_d2v) + (r["raw_method_norm"]*w_method) + (r["raw_cat_norm"]*w_cat)) / total_weight
455
+ new_total_score = path_score + weighted_sum
456
+ new_subs = path_subs + [r["cand"]]
457
+ new_ctx = current_ctx_ing + [r["cand"]]
458
+ next_beam.append((new_total_score, new_subs, new_ctx))
459
+ next_beam.sort(key=lambda x: x[0], reverse=True)
460
+ beam = next_beam[:beam_width]
461
+ final_results = []
462
+ for score, subs, _ in beam:
463
+ avg_score = score / len(targets) if targets else 0.0
464
+ cand_ranks_sum = 0
465
+ for sub_ing in subs: cand_ranks_sum += get_estimated_price_rank(sub_ing, price_map)
466
+ total_saving_score = target_ranks_sum - cand_ranks_sum
467
+ final_results.append((subs, avg_score, total_saving_score))
468
+ return final_results[:result_topn]
469
+
470
+ # ==========================================
471
+ # 5. ์ปค์Šคํ…€ ์ž…๋ ฅ ๊ธฐ๋ฐ˜ ๋Œ€์ฒด ์•Œ๊ณ ๋ฆฌ์ฆ˜ (์ˆ˜์ •๋จ)
472
+ # ==========================================
473
+ def substitute_single_custom(target_ing, context_ings_list, user_stopwords, w_w2v, w_d2v, excluded_ings=None, topn=10):
474
+ if target_ing not in w2v_model.wv: return pd.DataFrame()
475
+ total_weight = w_w2v + w_d2v
476
+ if total_weight == 0: total_weight = 1.0
477
+ vec_custom_context = None
478
+ if w_d2v > 0:
479
+ valid_context = [word for word in context_ings_list if word in d2v_model.wv]
480
+ if valid_context: vec_custom_context = d2v_model.infer_vector(valid_context)
481
+ target_rank = get_estimated_price_rank(target_ing, price_map)
482
+ candidates_raw = w2v_model.wv.most_similar(target_ing, topn=50)
483
+ temp_results = []
484
+ seen_candidates = set()
485
+
486
+ # [์ˆ˜์ •] ์‹ค์‹œ๊ฐ„ ๋กœ๋“œ
487
+ global_stopwords_set = set(load_global_stopwords())
488
+ final_stopwords = set(user_stopwords) | global_stopwords_set
489
+ excluded_set = set(excluded_ings) if excluded_ings else set()
490
+
491
+ for cand, score_w2v in candidates_raw:
492
+ clean_cand = cand
493
+ if final_stopwords:
494
+ for stop in final_stopwords: clean_cand = clean_cand.replace(stop, "")
495
+ clean_cand = clean_cand.strip()
496
+
497
+ if not clean_cand: continue
498
+ if clean_cand in final_stopwords: continue
499
+ if clean_cand in excluded_set: continue
500
+
501
+ if clean_cand in context_ings_list: continue
502
+ if clean_cand == target_ing: continue
503
+ if clean_cand not in w2v_model.wv: continue
504
+ if clean_cand in seen_candidates: continue
505
+ seen_candidates.add(clean_cand)
506
+ real_score_w2v = w2v_model.wv.similarity(target_ing, clean_cand)
507
+ s_w2v = max(0.0, real_score_w2v)
508
+ if s_w2v < 0.35: continue
509
+ s_d2v = 0.0
510
+ if w_d2v > 0 and vec_custom_context is not None:
511
+ rid_list = recipes_by_ingredient.get(clean_cand, [])
512
+ if len(rid_list) > 20:
513
+ np.random.seed(42)
514
+ rid_list = np.random.choice(rid_list, 20, replace=False)
515
+ if rid_list is not None and len(rid_list) > 0:
516
+ sims = []
517
+ for r in rid_list:
518
+ rt = f"recipe_{r}"
519
+ if rt in d2v_model.dv: sims.append(cos_sim(vec_custom_context, d2v_model.dv[rt]))
520
+ if sims: s_d2v = np.mean(sims)
521
+ s_method, s_cat = 0.0, 0.0
522
+ cand_rank = get_estimated_price_rank(clean_cand, price_map)
523
+ saving_score = target_rank - cand_rank
524
+ temp_results.append({"๋Œ€์ฒด์žฌ๋ฃŒ": clean_cand, "raw_W2V": s_w2v, "raw_D2V": s_d2v, "raw_Method": s_method, "raw_Category": s_cat, "saving_score": saving_score})
525
+ if not temp_results: return pd.DataFrame()
526
+ df_res = pd.DataFrame(temp_results)
527
+ cols = ["raw_W2V", "raw_D2V"]
528
+ norm_cols = ["W2V", "D2V"]
529
+ for raw_col, norm_col in zip(cols, norm_cols):
530
+ min_val = df_res[raw_col].min()
531
+ max_val = df_res[raw_col].max()
532
+ if max_val - min_val == 0: df_res[norm_col] = 0.5
533
+ else: df_res[norm_col] = (df_res[raw_col] - min_val) / (max_val - min_val)
534
+ df_res["์ตœ์ข…์ ์ˆ˜"] = ((df_res["W2V"]*w_w2v) + (df_res["D2V"]*w_d2v)) / total_weight
535
+ return df_res.sort_values("์ตœ์ข…์ ์ˆ˜", ascending=False).head(topn).reset_index(drop=True)
536
+
537
+ def substitute_multi_custom(targets, context_ings_list, user_stopwords, w_w2v, w_d2v, excluded_ings=None, beam_width=3, result_topn=3):
538
+ total_weight = w_w2v + w_d2v
539
+ if total_weight == 0: total_weight = 1.0
540
+ vec_custom_context = None
541
+ if w_d2v > 0:
542
+ valid_context = [word for word in context_ings_list if word in d2v_model.wv]
543
+ if valid_context: vec_custom_context = d2v_model.infer_vector(valid_context)
544
+ target_ranks_sum = 0
545
+ for t in targets: target_ranks_sum += get_estimated_price_rank(t, price_map)
546
+
547
+ # [์ˆ˜์ •] ์‹ค์‹œ๊ฐ„ ๋กœ๋“œ
548
+ global_stopwords_set = set(load_global_stopwords())
549
+ final_stopwords = set(user_stopwords) | global_stopwords_set
550
+ excluded_set = set(excluded_ings) if excluded_ings else set()
551
+
552
+ beam = [(0.0, [], context_ings_list)]
553
+ for target_ing in targets:
554
+ next_beam = []
555
+ if target_ing not in w2v_model.wv:
556
+ for score, subs, ctx in beam: next_beam.append((score, subs + [target_ing], ctx))
557
+ beam = next_beam
558
+ continue
559
+ for path_score, path_subs, path_ctx in beam:
560
+ current_ctx_ing = [x for x in path_ctx if x != target_ing]
561
+ candidates = w2v_model.wv.most_similar(target_ing, topn=30)
562
+ temp_candidates = []
563
+ seen_candidates = set()
564
+ for cand, _ in candidates:
565
+ clean_cand = cand
566
+ if final_stopwords:
567
+ for stop in final_stopwords: clean_cand = clean_cand.replace(stop, "")
568
+ clean_cand = clean_cand.strip()
569
+
570
+ if not clean_cand: continue
571
+ if clean_cand in final_stopwords: continue
572
+ if clean_cand in excluded_set: continue
573
+
574
+ if clean_cand in current_ctx_ing or clean_cand in path_subs: continue
575
+ if clean_cand == target_ing: continue
576
+ if clean_cand not in w2v_model.wv: continue
577
+ if clean_cand in seen_candidates: continue
578
+ seen_candidates.add(clean_cand)
579
+ sim_orig = w2v_model.wv.similarity(target_ing, clean_cand)
580
+ sim_orig = max(0.0, sim_orig)
581
+ if sim_orig < 0.3: continue
582
+ harmony_scores = [w2v_model.wv.similarity(clean_cand, c) for c in current_ctx_ing if c in w2v_model.wv]
583
+ sim_harmony = np.mean(harmony_scores) if harmony_scores else 0.0
584
+ s_w2v = 0.5 * sim_orig + 0.5 * max(0.0, sim_harmony)
585
+ s_d2v = 0.0
586
+ if w_d2v > 0:
587
+ valid_path_ctx = [word for word in current_ctx_ing if word in d2v_model.wv]
588
+ if valid_path_ctx:
589
+ vec_path_context = d2v_model.infer_vector(valid_path_ctx)
590
+ rid_list = recipes_by_ingredient.get(clean_cand, [])
591
+ if len(rid_list) > 10:
592
+ np.random.seed(42)
593
+ rid_list = np.random.choice(rid_list, 10, replace=False)
594
+ if rid_list is not None and len(rid_list) > 0:
595
+ sims = []
596
+ for r in rid_list:
597
+ rt = f"recipe_{r}"
598
+ if rt in d2v_model.dv: sims.append(cos_sim(vec_path_context, d2v_model.dv[rt]))
599
+ if sims: s_d2v = np.mean(sims)
600
+ s_method, s_cat = 0.0, 0.0
601
+ temp_candidates.append({"cand": clean_cand, "raw_w2v": s_w2v, "raw_d2v": s_d2v})
602
+ if not temp_candidates: continue
603
+ df_temp = pd.DataFrame(temp_candidates)
604
+ cols = ["raw_w2v", "raw_d2v"]
605
+ for col in cols:
606
+ min_val = df_temp[col].min()
607
+ max_val = df_temp[col].max()
608
+ if max_val - min_val == 0: df_temp[col + "_norm"] = 0.5
609
+ else: df_temp[col + "_norm"] = (df_temp[col] - min_val) / (max_val - min_val)
610
+ for _, r in df_temp.iterrows():
611
+ weighted_sum = ((r["raw_w2v_norm"]*w_w2v) + (r["raw_d2v_norm"]*w_d2v)) / total_weight
612
+ new_total_score = path_score + weighted_sum
613
+ new_subs = path_subs + [r["cand"]]
614
+ new_ctx = current_ctx_ing + [r["cand"]]
615
+ next_beam.append((new_total_score, new_subs, new_ctx))
616
+ next_beam.sort(key=lambda x: x[0], reverse=True)
617
+ beam = next_beam[:beam_width]
618
+ final_results = []
619
+ for score, subs, _ in beam:
620
+ avg_score = score / len(targets) if targets else 0.0
621
+ cand_ranks_sum = 0
622
+ for sub_ing in subs: cand_ranks_sum += get_estimated_price_rank(sub_ing, price_map)
623
+ total_saving_score = target_ranks_sum - cand_ranks_sum
624
+ final_results.append((subs, avg_score, total_saving_score))
625
+ return final_results[:result_topn]
626
+
627
+ # ==========================================
628
+ # 6. ์žฌ๋ฃŒ ํ‚ค์›Œ๋“œ ๊ธฐ๋ฐ˜ ๋ ˆ์‹œํ”ผ ๊ฒ€์ƒ‰ (๊ธฐ์กด๊ณผ ๋™์ผ)
629
+ # ==========================================
630
+ def find_recipes_by_ingredient_keyword(keyword, topn=5):
631
+ keyword = keyword.strip()
632
+ if not keyword: return []
633
+ matched_dishes = set()
634
+ for _, row in df.iterrows():
635
+ for ing in row['์žฌ๋ฃŒํ† ํฐ']:
636
+ if keyword in ing:
637
+ matched_dishes.add(row['์š”๋ฆฌ๋ช…'])
638
+ break
639
+ return list(matched_dishes)[:topn]