
โ Week 5 Progress: Ch 06 ~ 07
๐ถBasic Mission (Required)
PROJECT 9. Create and capture the "Please Take Care of My Refrigerator" app


โ
๐Additional Mission (Optional)
Building an AI Agent Team
Use AI design agents to improve the UX design of the "Please Take Care of My Refrigerator" app and capture it




๐ ์์
์์ฝ
ํ๋ก์ ํธ๋ช
: Smart Recipe - AI ๋์ฅ๊ณ ๋ ์ํผ ์ถ์ฒ
์ฃผ์ ๊ธฐ๋ฅ (3๋จ๊ณ๋ก ๊ตฌ์ฑ):
Step 1: ๋์ฅ๊ณ /์์ฌ๋ฃ ์ด๋ฏธ์ง ์
๋ก๋ โ AI๊ฐ ์ฌ๋ฃ ์๋ ์ธ์ (Gemma 3 ๋ชจ๋ธ)
Step 2: ์ธ์๋ ์ฌ๋ฃ ๊ธฐ๋ฐ โ AI ๋ง์ถค ๋ ์ํผ ์์ฑ (DeepSeek ๋ชจ๋ธ)
Step 3: ์ฌ์ฉ์ ์ธ์ฆ(ํ์๊ฐ์
/๋ก๊ทธ์ธ) + ๋ ์ํผ ์ ์ฅ ๋ฐ ๊ด๋ฆฌ
๊ธฐ์ ์คํ:
Backend: Flask (Python)
Database: SQLite
Frontend: Vanilla HTML/CSS/JavaScript
AI API: OpenRouter (Gemma 3, DeepSeek ๋ฌด๋ฃ ๋ชจ๋ธ)
์์ฑ๋ README.md ๋ด์ฉ
ํ๋ก์ ํธ ๊ฐ์ ๋ฐ ๋ฐฐ์ง
์ฃผ์ ๊ธฐ๋ฅ ํ
์ด๋ธ
ํ๋ก์ ํธ ๊ตฌ์กฐ ํธ๋ฆฌ
๊ธฐ์ ์คํ ์์ธ
์ค์น ๋ฐ ์คํ ๊ฐ์ด๋
API ์๋ํฌ์ธํธ ๋ฌธ์
ํ
์คํธ ์คํ ๋ฐฉ๋ฒ
์ฌ์ฉ ํ๋ฆ (Mermaid ๋ค์ด์ด๊ทธ๋จ)
๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง
๊ธฐ์ฌ ๊ฐ์ด๋
Git ์ปค๋ฐ ๋ฐ ํธ์
๐ GitHub์์ ํ์ธ: https://github.com/sechan9999/recipe
https://sechan9999.github.io/recipe/
https://github.com/sechan9999/AIdiaryPdfSummarizer
https://sechan9999.github.io/AIdiaryPdfSummarizer/

โ

๋ณธ ๊ฐ์๋ ใํผ์ ๊ณต๋ถํ๋ ๋ฐ์ด๋ธ ์ฝ๋ฉ with ํด๋ก๋ ์ฝ๋ใ์ ๋ด์ฉ์ ๋ฐํ์ผ๋ก ์ ์ํ์์ต๋๋ค.
๐จโ๐ซ์ฃผ์ ๊ฐ์ ๋ด์ฉ
Ch 06. ํด๋ก๋ ์ฝ๋์ API ๋ ๊ฐ ๋ฌ๊ธฐ
06-1 ํ๋ก๋ ์ฝ๋์์ API ์ค์ ํ๊ธฐ
06-2 ํด๋ก๋ ์ฝ๋์ API๋ก ๋ง๋๋ '๋์ฅ๊ณ ๋ฅผ ๋ถํํด'
๐จโ๐ซ์ฃผ์ ๊ฐ์ ๋ด์ฉ
Ch 07. ํด๋ก๋ ์ฝ๋ AI ์์ด์ ํธ๋ก ๊ฐ๋ฐํ ๊ตฌ์ฑํ๊ธฐ
07-1 ํด๋ก๋ ์ฝ๋์ AI ์์ด์ ํธ ์ดํดํ๊ธฐ
07-2 AI ์์ด์ ํธ๋ก ์ํํธ์จ์ด ๊ฐ๋ฐ ์๋ํํ๊ธฐ
๐์ค์ต ์์ : https://github.com/taehojo/vibecoding
๐์ ์๋๊ป ์ง๋ฌธํ๊ธฐ: https://github.com/taehojo/vibecoding/issues
๐ํผ์ ๊ณต๋ถํ๋ ๋ฐ์ด๋ธ ์ฝ๋ฉ with ํด๋ก๋ ์ฝ๋: https://www.hanbit.co.kr/store/books/look.php?p_code=B1785590517&type=book&utm_source=inflearn&utm_medium=affiliate&utm_campaign=50004

๊ณต๊ฐ ๋ค์ด์ด๋ฆฌ ์ฑ
AI ๊ณต๊ฐ ๋ค์ด์ด๋ฆฌ๋ฅผ ๋ง๋ค์ด ์ค. ์ค๋ ์์๋ ์ผ์ ํ ์ค๋ก ์ฐ๋ฉด, AI๊ฐ ๊ฐ์ ์ ๋ถ์ํ๊ณ ๊ณต๊ฐํ๋ฉฐ ์๋กํด ์ฃผ๋ ์ผ๊ธฐ ์ ํ๋ฆฌ์ผ์ด์
์ด์ผ.
backend-architect๊ฐ OpenRouter API๋ฅผ ์ฐ๋ํด์ ๊ฐ์ ๋ถ์๊ณผ ๊ณต๊ฐ ๋ฉ์์ง๋ฅผ ์์ฑํ๋ ๊ธฐ๋ฅ์ ๊ตฌํํด ์ค.
DeepSeek V3.1 ๋ฌด๋ฃ ๋ชจ๋ธ์ ์ฌ์ฉํ๊ณ , API ํค๋ ํ์ฌ ํด๋์ โ.envโ ํ์ผ์ ์ ์ฅ๋ ๊ฒ์ ์ฌ์ฉํด.
frontend-developer๊ฐ ๋ฐ๋ปํ๊ณ ํธ์ํ ๋๋์ ์ผ๊ธฐ์ฅ UI๋ฅผ ๋ง๋ ๋ค์, qa-engineer๊ฐ ์ค์ ๋ก ์ฌ๋ฌ ์ํฉ์์ ๋ฌธ์ ์์ด ์๋ํ๋์ง ํ
์คํธํด ์ค.
๋ฌธ์ ๋ฅผ ๋ฐ๊ฒฌํ๋ฉด ์์ ํ ํด๊ฒฐํ ๋๊น์ง ์์ ํ๊ณ , ์ต์ข
๋ฒ์ ์ ๋ธ๋ผ์ฐ์ ์์ ๋ฐ๋ก ์ด ์ ์๋ โindex.htmlโ ํ์ผ๋ก ๋ง๋ค์ด ์ค.


PDF ๋ฌธ์๋ฅผ ์
๋ก๋ํ๋ฉด AI๊ฐ ์์ฝํด์ฃผ๋ ์น ์ ํ๋ฆฌ์ผ์ด์
์ ๋ง๋ค ๊ฑฐ์ผ.
๋จผ์ product-manager-prd๊ฐ PDF ๋ฌธ์ ์์ฝ ์ฑ์ ์์ธ PRD์ ๊ธฐ๋ฅ ๋ช
์ธ๋ฅผ ์์ฑํ๊ณ ,
backend-architect๊ฐ PDF ํ์ผ ์
๋ก๋, ํ
์คํธ ์ถ์ถ ๊ธฐ๋ฅ์ ๊ตฌํํด.
ai-integration-specialist๊ฐ OpenRouter API๋ฅผ ์ฐ๋ํด์ ์ถ์ถ๋ ํ
์คํธ๋ฅผ ์์ฝํ๋ ๊ธฐ๋ฅ์ ๊ตฌํํด ์ค.
DeepSeek V3.1 ๋ฌด๋ฃ ๋ชจ๋ธ์ ์ฌ์ฉํ๊ณ , API ํค๋ ํ์ฌ ํด๋์ โ.envโ ํ์ผ์ ์ ์ฅ๋ ๊ฒ์ ์ฌ์ฉํด.
frontend-developer๊ฐ ๋๋๊ทธ&๋๋กญ ํ์ผ ์
๋ก๋ UI์ ์์ฝ ๊ฒฐ๊ณผ๋ฅผ ๊น๋ํ๊ฒ ํ์ํ๋ ํ๊ธ ์ธํฐํ์ด์ค๋ฅผ ๊ตฌํํ ๋ค์,
qa-engineer๊ฐ ์ค์ ๋ก ์ฌ๋ฌ ์ํฉ์์ ๋ฌธ์ ์์ด ์๋ํ๋์ง ํ
์คํธํด ์ค. ๋ฌธ์ ๋ฅผ ๋ฐ๊ฒฌํ๋ฉด ์์ ํ ํด๊ฒฐํ ๋๊น์ง ์์ ํ๊ณ , ์ต์ข
๋ฒ์ ์ ๋ธ๋ผ์ฐ์ ์์ ๋ฐ๋ก ์ด ์ ์๋ โindex_pdf.htmlโ ํ์ผ๋ก ๋ง๋ค์ด ์ค.




"""
๋์ฅ๊ณ ์ฌ๋ฃ ์ธ์ ์น ์ ํ๋ฆฌ์ผ์ด์
- Step 1, 2 & 3
Flask ๋ฐฑ์๋ ์๋ฒ
"""
import os
import json
import re
import sqlite3
import secrets
import logging
from datetime import datetime
import urllib.request
import urllib.error
from functools import wraps
from flask import Flask, render_template, request, jsonify, session, g
from dotenv import load_dotenv
import bcrypt
load_dotenv()
# ๋ก๊น
์ค์
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
app = Flask(__name__)
# ๋ณด์ ์ค์
MAX_CONTENT_SIZE = 10 * 1024 * 1024 # 10MB
app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_SIZE
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', secrets.token_hex(32))
# ์ธ์
์ฟ ํค ๋ณด์ ์ค์
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# HTTPS ํ๊ฒฝ์์๋ง ํ์ฑํ (๊ฐ๋ฐ ์ False)
app.config['SESSION_COOKIE_SECURE'] = os.getenv('FLASK_ENV') == 'production'
OPENROUTER_API_KEY = os.getenv('OPENROUTER_API_KEY')
# ํ๊ฒฝ๋ณ์ ๊ฒ์ฆ
if not OPENROUTER_API_KEY:
logging.warning("OPENROUTER_API_KEY is not set. API calls will fail.")
BASE_URL = "https://openrouter.ai/api/v1"
DATABASE = 'smart_recipe.db'
# ์ด๋ฏธ์ง ์ธ์ ๋ชจ๋ธ (๋น์ ์ง์ ๋ฌด๋ฃ ๋ชจ๋ธ - ์ฐ์ ์์)
IMAGE_MODELS = [
"qwen/qwen2.5-vl-72b-instruct:free",
"qwen/qwen2.5-vl-32b-instruct:free",
"meta-llama/llama-4-scout:free",
"moonshotai/kimi-vl-a3b-thinking:free",
"google/gemma-3-27b-it:free",
]
# ํ
์คํธ ์์ฑ ๋ชจ๋ธ (๋ ์ํผ์ฉ)
TEXT_MODELS = [
"qwen/qwen3-235b-a22b:free",
"google/gemma-3-27b-it:free",
"deepseek/deepseek-r1-0528:free",
"meta-llama/llama-4-maverick:free",
]
# ===== ๋ฐ์ดํฐ๋ฒ ์ด์ค =====
def get_db():
"""๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ"""
if 'db' not in g:
g.db = sqlite3.connect(DATABASE)
g.db.row_factory = sqlite3.Row
return g.db
@app.teardown_appcontext
def close_db(exception):
"""์์ฒญ ์ข
๋ฃ ์ DB ์ฐ๊ฒฐ ํด์ """
db = g.pop('db', None)
if db is not None:
db.close()
def init_db():
"""๋ฐ์ดํฐ๋ฒ ์ด์ค ์ด๊ธฐํ"""
db = sqlite3.connect(DATABASE)
db.executescript('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
nickname TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE REFERENCES users(id) ON DELETE CASCADE,
allergies TEXT DEFAULT '[]',
dietary_restrictions TEXT DEFAULT '[]',
preferred_cuisines TEXT DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS saved_recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
recipe_name TEXT NOT NULL,
recipe_data TEXT NOT NULL,
ingredients TEXT DEFAULT '[]',
cuisine_type TEXT,
difficulty TEXT,
cook_time TEXT,
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
notes TEXT,
tags TEXT DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS analysis_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
detected_ingredients TEXT DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ์ฑ๋ฅ ์ต์ ํ ์ธ๋ฑ์ค
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_saved_recipes_user_id ON saved_recipes(user_id);
CREATE INDEX IF NOT EXISTS idx_saved_recipes_created_at ON saved_recipes(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_analysis_history_user_id ON analysis_history(user_id);
''')
db.commit()
db.close()
# ์ฑ ์์ ์ DB ์ด๊ธฐํ
with app.app_context():
init_db()
def hash_password(password):
"""๋น๋ฐ๋ฒํธ ํด์ํ (bcrypt ์ฌ์ฉ)"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def verify_password(password, hashed):
"""๋น๋ฐ๋ฒํธ ๊ฒ์ฆ (SHA256 โ bcrypt ๋ง์ด๊ทธ๋ ์ด์
์ง์)"""
# bcrypt ํด์๋ $2๋ก ์์ (์: $2b$12$...)
if hashed.startswith('$2'):
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
else:
# ๊ธฐ์กด SHA256 ํด์ ํธํ (64์ hex)
import hashlib
return hashed == hashlib.sha256(password.encode()).hexdigest()
def upgrade_password_hash(user_id, password):
"""SHA256 ํด์๋ฅผ bcrypt๋ก ์
๊ทธ๋ ์ด๋"""
db = get_db()
new_hash = hash_password(password)
db.execute('UPDATE users SET password_hash = ? WHERE id = ?', (new_hash, user_id))
db.commit()
def login_required(f):
"""๋ก๊ทธ์ธ ํ์ ๋ฐ์ฝ๋ ์ดํฐ"""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return jsonify({"success": False, "error": "๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค"}), 401
return f(*args, **kwargs)
return decorated_function
def get_current_user():
"""ํ์ฌ ๋ก๊ทธ์ธํ ์ฌ์ฉ์ ์กฐํ"""
if 'user_id' not in session:
return None
db = get_db()
user = db.execute('SELECT * FROM users WHERE id = ?', (session['user_id'],)).fetchone()
return dict(user) if user else None
# ===== OpenRouter API =====
def call_openrouter(model, messages, timeout=60):
"""OpenRouter API ํธ์ถ"""
url = f"{BASE_URL}/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"HTTP-Referer": "http://localhost:5000",
"X-Title": "Smart Recipe"
}
data = json.dumps({
"model": model,
"messages": messages
}).encode('utf-8')
logging.info(f"[API] ๋ชจ๋ธ ํธ์ถ: {model}")
req = urllib.request.Request(url, data=data, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
result = json.loads(response.read().decode('utf-8'))
logging.info(f"[API] ์ฑ๊ณต: {model}")
return result
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8')
logging.error(f"[API] HTTP {e.code} ์๋ฌ ({model}): {error_body[:500]}")
try:
return {"error": json.loads(error_body)}
except:
return {"error": {"message": f"HTTP {e.code}: {error_body[:200]}"}}
except urllib.error.URLError as e:
logging.error(f"[API] URL ์๋ฌ ({model}): {e.reason}")
return {"error": {"message": f"์ฐ๊ฒฐ ์คํจ: {e.reason}"}}
except Exception as e:
logging.error(f"[API] ์์ธ ({model}): {str(e)}")
return {"error": {"message": str(e)}}
# ===== Step 1: ์ด๋ฏธ์ง ๋ถ์ =====
def extract_ingredients(text):
"""ํ
์คํธ์์ ์ฌ๋ฃ ๋ฐฐ์ด ์ถ์ถ"""
json_match = re.search(r'\[.*?\]', text, re.DOTALL)
if json_match:
try:
ingredients = json.loads(json_match.group())
if isinstance(ingredients, list):
return [str(i).strip() for i in ingredients if i]
except json.JSONDecodeError:
pass
lines = re.split(r'[,\n]', text)
ingredients = []
for line in lines:
cleaned = re.sub(r'^[\d\.\-\*\โข]+\s*', '', line.strip())
cleaned = re.sub(r'["\'\[\]]', '', cleaned)
if cleaned and len(cleaned) > 1 and len(cleaned) < 50:
ingredients.append(cleaned)
return ingredients[:30]
def analyze_image(base64_image, mime_type="image/jpeg"):
"""์ด๋ฏธ์ง ๋ถ์ํ์ฌ ์ฌ๋ฃ ์ถ์ถ"""
prompt = """์ด ๋์ฅ๊ณ /์์ฌ๋ฃ ์ฌ์ง์์ ๋ณด์ด๋ ๋ชจ๋ ์์ฌ๋ฃ๋ฅผ ๋ถ์ํด์ฃผ์ธ์.
๋ค์ JSON ํ์์ผ๋ก๋ง ์๋ตํด์ฃผ์ธ์:
["์ฌ๋ฃ1", "์ฌ๋ฃ2", "์ฌ๋ฃ3"]
์์: ["๊ณ๋", "์ฐ์ ", "๋น๊ทผ", "์ํ", "๋ผ์ง๊ณ ๊ธฐ"]
์ฃผ์์ฌํญ:
- ๋ณด์ด๋ ์์ฌ๋ฃ๋ง ๋์ด
- ํ๊ธ๋ก ์์ฑ
- JSON ๋ฐฐ์ด ํ์๋ง ์ถ๋ ฅ"""
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:{mime_type};base64,{base64_image}"
}
}
]
}
]
last_error = "์ ์ ์๋ ์ค๋ฅ"
for model in IMAGE_MODELS:
result = call_openrouter(model, messages, timeout=65)
if "error" not in result:
try:
content = result['choices'][0]['message']['content']
ingredients = extract_ingredients(content)
if ingredients:
return {
"success": True,
"ingredients": ingredients,
"model": model,
"raw_response": content
}
else:
logging.warning(f"[๋ถ์] {model}: ์ฌ๋ฃ ์ถ์ถ ์คํจ - ์๋ต: {content[:200]}")
last_error = "์ด๋ฏธ์ง์์ ์ฌ๋ฃ๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค"
continue
except (KeyError, IndexError) as e:
logging.error(f"[๋ถ์] {model}: ์๋ต ํ์ฑ ์คํจ - {e}, ์๋ต: {str(result)[:300]}")
last_error = "API ์๋ต ํ์ ์ค๋ฅ"
continue
# ์๋ฌ ๋ฉ์์ง ์ถ์ถ (๋ค์ํ ์๋ฌ ๊ตฌ์กฐ ๋์)
err = result.get('error', {})
if isinstance(err, dict):
err_msg = err.get('message', '') or err.get('error', {}).get('message', '')
err_code = err.get('code', '') or err.get('error', {}).get('code', '')
else:
err_msg = str(err)
err_code = ''
last_error = err_msg or "API ํธ์ถ ์คํจ"
logging.warning(f"[๋ถ์] {model} ์คํจ: code={err_code}, msg={err_msg[:200]}")
# 429(Rate Limit)๋ ๋ค์ ๋ชจ๋ธ ์๋, ๊ทธ ์ธ ์น๋ช
์ ์๋ฌ๋ ๋ค์ ๋ชจ๋ธ ์๋
continue
logging.error(f"[๋ถ์] ๋ชจ๋ ๋ชจ๋ธ ์คํจ. ๋ง์ง๋ง ์๋ฌ: {last_error}")
return {
"success": False,
"error": last_error,
"ingredients": []
}
# ===== Step 2: ๋ ์ํผ ์์ฑ =====
def extract_recipe_json(text):
"""ํ
์คํธ์์ ๋ ์ํผ JSON ์ถ์ถ"""
json_match = re.search(r'\{[\s\S]*\}', text)
if json_match:
try:
recipe = json.loads(json_match.group())
return recipe
except json.JSONDecodeError:
pass
return parse_recipe_text(text)
def parse_recipe_text(text):
"""ํ
์คํธ๋ฅผ ๋ ์ํผ ๊ตฌ์กฐ๋ก ํ์ฑ"""
recipe = {
"name": "์ถ์ฒ ๋ ์ํผ",
"description": "",
"difficulty": "์ค๊ธ",
"cookTime": "30๋ถ",
"servings": 2,
"ingredients": [],
"steps": [],
"tips": ""
}
lines = text.split('\n')
current_section = None
for line in lines:
line = line.strip()
if not line:
continue
if '์๋ฆฌ' in line and ('์ด๋ฆ' in line or '๋ช
' in line):
current_section = 'name'
elif '์ฌ๋ฃ' in line:
current_section = 'ingredients'
elif '์์' in line or '๋จ๊ณ' in line or '๋ฐฉ๋ฒ' in line or '๊ณผ์ ' in line:
current_section = 'steps'
elif 'ํ' in line or '์ฐธ๊ณ ' in line:
current_section = 'tips'
elif '์ค๋ช
' in line:
current_section = 'description'
elif current_section == 'name' and ':' in line:
recipe['name'] = line.split(':', 1)[1].strip()
elif current_section == 'ingredients':
cleaned = re.sub(r'^[\d\.\-\*\โข]+\s*', '', line)
if cleaned and len(cleaned) > 1:
recipe['ingredients'].append({
"name": cleaned,
"amount": "",
"available": True
})
elif current_section == 'steps':
cleaned = re.sub(r'^[\d\.\-\*\โข]+\s*', '', line)
if cleaned and len(cleaned) > 5:
recipe['steps'].append(cleaned)
elif current_section == 'tips':
recipe['tips'] += line + " "
if recipe['name'] == "์ถ์ฒ ๋ ์ํผ" and lines:
first_meaningful = next((l for l in lines if len(l.strip()) > 2), None)
if first_meaningful:
recipe['name'] = first_meaningful.strip()[:50]
return recipe
def generate_recipe(ingredients, cuisine, difficulty, cook_time, servings):
"""AI๋ก ๋ ์ํผ ์์ฑ"""
prompt = f"""๋น์ ์ ์ ๋ฌธ ์๋ฆฌ์ฌ์
๋๋ค. ์ฃผ์ด์ง ์ฌ๋ฃ๋ก ๋ง์๋ ์๋ฆฌ ๋ ์ํผ๋ฅผ ์ถ์ฒํด์ฃผ์ธ์.
์ฌ์ฉ ๊ฐ๋ฅํ ์ฌ๋ฃ: {', '.join(ingredients)}
์๋ฆฌ ์ข
๋ฅ: {cuisine}
๋์ด๋: {difficulty}
์กฐ๋ฆฌ ์๊ฐ: {cook_time}
์ธ์: {servings}์ธ๋ถ
๋ฐ๋์ ๋ค์ JSON ํ์์ผ๋ก๋ง ์๋ตํด์ฃผ์ธ์:
{{
"name": "์๋ฆฌ ์ด๋ฆ",
"description": "์๋ฆฌ์ ๋ํ ๊ฐ๋จํ ์ค๋ช
(1-2๋ฌธ์ฅ)",
"difficulty": "{difficulty}",
"cookTime": "{cook_time}",
"servings": {servings},
"ingredients": [
{{"name": "์ฌ๋ฃ๋ช
", "amount": "๋ถ๋", "available": true}},
{{"name": "์ถ๊ฐ ํ์ํ ์ฌ๋ฃ", "amount": "๋ถ๋", "available": false}}
],
"steps": [
"1. ์ฒซ ๋ฒ์งธ ์กฐ๋ฆฌ ๋จ๊ณ",
"2. ๋ ๋ฒ์งธ ์กฐ๋ฆฌ ๋จ๊ณ",
"3. ์ธ ๋ฒ์งธ ์กฐ๋ฆฌ ๋จ๊ณ"
],
"tips": "์กฐ๋ฆฌ ํ์ด๋ ์ฃผ์์ฌํญ"
}}
์ฃผ์์ฌํญ:
- ์ฃผ์ด์ง ์ฌ๋ฃ๋ฅผ ์ต๋ํ ํ์ฉํ์ธ์
- ์ถ๊ฐ๋ก ํ์ํ ๊ธฐ๋ณธ ์ฌ๋ฃ(์๊ธ, ์คํ, ์์ฉ์ ๋ฑ)๋ available: false๋ก ํ์
- ๋จ๊ณ๋ ๊ตฌ์ฒด์ ์ด๊ณ ๋ฐ๋ผํ๊ธฐ ์ฝ๊ฒ ์์ฑ
- ๋ฐ๋์ ์ ํจํ JSON ํ์์ผ๋ก ์๋ต"""
messages = [
{"role": "user", "content": prompt}
]
last_error = None
for model in TEXT_MODELS:
result = call_openrouter(model, messages, timeout=90)
if "error" not in result:
content = result['choices'][0]['message']['content']
recipe = extract_recipe_json(content)
return {
"success": True,
"recipe": recipe,
"model": model,
"raw_response": content
}
last_error = result.get('error', {})
error_code = last_error.get('code')
if error_code and error_code != 429:
break
return {
"success": False,
"error": last_error.get('message', '์ ์ ์๋ ์ค๋ฅ') if last_error else '์ ์ ์๋ ์ค๋ฅ',
"recipe": None
}
# ===== ๋ผ์ฐํธ: ํ์ด์ง =====
@app.route('/')
def index():
"""๋ฉ์ธ ํ์ด์ง"""
return render_template('index.html')
# ===== ๋ผ์ฐํธ: Step 1 & 2 API =====
@app.route('/api/analyze', methods=['POST'])
def analyze():
"""์ด๋ฏธ์ง ๋ถ์ API"""
try:
data = request.get_json()
if not data or 'image' not in data:
return jsonify({"success": False, "error": "์ด๋ฏธ์ง๊ฐ ํ์ํฉ๋๋ค"}), 400
image_data = data['image']
if ',' in image_data:
header, base64_image = image_data.split(',', 1)
mime_match = re.search(r'data:([^;]+)', header)
mime_type = mime_match.group(1) if mime_match else 'image/jpeg'
else:
base64_image = image_data
mime_type = 'image/jpeg'
# ์ด๋ฏธ์ง ํฌ๊ธฐ ๊ฒ์ฆ (base64 โ ์ค์ ํฌ๊ธฐ ์ฝ 3/4)
img_size_mb = len(base64_image) * 3 / 4 / (1024 * 1024)
logging.info(f"[๋ถ์] ์ด๋ฏธ์ง ํฌ๊ธฐ: {img_size_mb:.1f}MB, MIME: {mime_type}")
if img_size_mb > 8:
return jsonify({"success": False, "error": "์ด๋ฏธ์ง๊ฐ ๋๋ฌด ํฝ๋๋ค (์ต๋ 8MB)"}), 400
result = analyze_image(base64_image, mime_type)
# ๋ก๊ทธ์ธํ ์ฌ์ฉ์๋ฉด ํ์คํ ๋ฆฌ ์ ์ฅ (์๋ฌ ๋ฐ์ ์ ๋กค๋ฐฑ)
if 'user_id' in session and result.get('success'):
db = get_db()
try:
db.execute(
'INSERT INTO analysis_history (user_id, detected_ingredients) VALUES (?, ?)',
(session['user_id'], json.dumps(result['ingredients']))
)
db.commit()
except Exception as e:
db.rollback()
logging.error(f"Failed to save analysis history: {e}")
return jsonify(result)
except Exception as e:
logging.error(f"[๋ถ์] ์๊ธฐ์น ์์ ์๋ฌ: {str(e)}", exc_info=True)
return jsonify({"success": False, "error": f"์๋ฒ ์ค๋ฅ: {str(e)}"}), 500
@app.route('/api/recipe', methods=['POST'])
def recipe():
"""๋ ์ํผ ์์ฑ API"""
data = request.get_json()
if not data or 'ingredients' not in data:
return jsonify({"success": False, "error": "์ฌ๋ฃ ๋ชฉ๋ก์ด ํ์ํฉ๋๋ค"}), 400
ingredients = data.get('ingredients', [])
cuisine = data.get('cuisine', '์๊ด์์')
difficulty = data.get('difficulty', '์ค๊ธ')
cook_time = data.get('cookTime', '30๋ถ ์ด๋ด')
servings = data.get('servings', 2)
if not ingredients:
return jsonify({"success": False, "error": "์ต์ 1๊ฐ ์ด์์ ์ฌ๋ฃ๊ฐ ํ์ํฉ๋๋ค"}), 400
result = generate_recipe(ingredients, cuisine, difficulty, cook_time, servings)
return jsonify(result)
# ===== Step 3: ์ธ์ฆ API =====
@app.route('/api/auth/register', methods=['POST'])
def register():
"""ํ์๊ฐ์
"""
data = request.get_json()
email = data.get('email', '').strip()
password = data.get('password', '')
nickname = data.get('nickname', '').strip()
if not email or not password:
return jsonify({"success": False, "error": "์ด๋ฉ์ผ๊ณผ ๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์"}), 400
if len(password) < 6:
return jsonify({"success": False, "error": "๋น๋ฐ๋ฒํธ๋ 6์ ์ด์์ด์ด์ผ ํฉ๋๋ค"}), 400
db = get_db()
existing = db.execute('SELECT id FROM users WHERE email = ?', (email,)).fetchone()
if existing:
return jsonify({"success": False, "error": "์ด๋ฏธ ๋ฑ๋ก๋ ์ด๋ฉ์ผ์
๋๋ค"}), 400
password_hash = hash_password(password)
cursor = db.execute(
'INSERT INTO users (email, password_hash, nickname) VALUES (?, ?, ?)',
(email, password_hash, nickname or email.split('@')[0])
)
db.commit()
user_id = cursor.lastrowid
db.execute('INSERT INTO user_preferences (user_id) VALUES (?)', (user_id,))
db.commit()
session['user_id'] = user_id
return jsonify({
"success": True,
"user": {"id": user_id, "email": email, "nickname": nickname or email.split('@')[0]}
})
@app.route('/api/auth/login', methods=['POST'])
def login():
"""๋ก๊ทธ์ธ"""
data = request.get_json()
email = data.get('email', '').strip()
password = data.get('password', '')
if not email or not password:
return jsonify({"success": False, "error": "์ด๋ฉ์ผ๊ณผ ๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์"}), 400
db = get_db()
user = db.execute('SELECT * FROM users WHERE email = ?', (email,)).fetchone()
if not user or not verify_password(password, user['password_hash']):
logging.warning(f"Failed login attempt for {email}")
return jsonify({"success": False, "error": "์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค"}), 401
# SHA256 โ bcrypt ์๋ ์
๊ทธ๋ ์ด๋
if not user['password_hash'].startswith('$2'):
upgrade_password_hash(user['id'], password)
logging.info(f"Password hash upgraded to bcrypt for user {user['id']}")
session['user_id'] = user['id']
return jsonify({
"success": True,
"user": {"id": user['id'], "email": user['email'], "nickname": user['nickname']}
})
@app.route('/api/auth/logout', methods=['POST'])
def logout():
"""๋ก๊ทธ์์"""
session.pop('user_id', None)
return jsonify({"success": True})
@app.route('/api/auth/me', methods=['GET'])
def get_me():
"""ํ์ฌ ์ฌ์ฉ์ ์ ๋ณด"""
user = get_current_user()
if not user:
return jsonify({"success": False, "user": None})
return jsonify({
"success": True,
"user": {
"id": user['id'],
"email": user['email'],
"nickname": user['nickname']
}
})
# ===== Step 3: ํ๋กํ API =====
@app.route('/api/profile', methods=['GET'])
@login_required
def get_profile():
"""ํ๋กํ ์กฐํ"""
user = get_current_user()
db = get_db()
prefs = db.execute(
'SELECT * FROM user_preferences WHERE user_id = ?',
(session['user_id'],)
).fetchone()
return jsonify({
"success": True,
"profile": {
"email": user['email'],
"nickname": user['nickname'],
"preferences": {
"allergies": json.loads(prefs['allergies']) if prefs else [],
"dietary_restrictions": json.loads(prefs['dietary_restrictions']) if prefs else [],
"preferred_cuisines": json.loads(prefs['preferred_cuisines']) if prefs else []
}
}
})
@app.route('/api/profile', methods=['PUT'])
@login_required
def update_profile():
"""ํ๋กํ ์์ """
data = request.get_json()
db = get_db()
if 'nickname' in data:
db.execute(
'UPDATE users SET nickname = ?, updated_at = ? WHERE id = ?',
(data['nickname'], datetime.now(), session['user_id'])
)
if 'preferences' in data:
prefs = data['preferences']
db.execute('''
UPDATE user_preferences SET
allergies = ?,
dietary_restrictions = ?,
preferred_cuisines = ?
WHERE user_id = ?
''', (
json.dumps(prefs.get('allergies', [])),
json.dumps(prefs.get('dietary_restrictions', [])),
json.dumps(prefs.get('preferred_cuisines', [])),
session['user_id']
))
db.commit()
return jsonify({"success": True})
# ===== Step 3: ๋ ์ํผ ์ ์ฅ API =====
@app.route('/api/recipes/save', methods=['POST'])
@login_required
def save_recipe():
"""๋ ์ํผ ์ ์ฅ"""
data = request.get_json()
recipe_data = data.get('recipe')
if not recipe_data:
return jsonify({"success": False, "error": "๋ ์ํผ ๋ฐ์ดํฐ๊ฐ ํ์ํฉ๋๋ค"}), 400
db = get_db()
cursor = db.execute('''
INSERT INTO saved_recipes
(user_id, recipe_name, recipe_data, ingredients, cuisine_type, difficulty, cook_time, notes, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
session['user_id'],
recipe_data.get('name', '์ ์ฅ๋ ๋ ์ํผ'),
json.dumps(recipe_data),
json.dumps(data.get('ingredients', [])),
data.get('cuisine_type', ''),
recipe_data.get('difficulty', ''),
recipe_data.get('cookTime', ''),
data.get('notes', ''),
json.dumps(data.get('tags', []))
))
db.commit()
return jsonify({"success": True, "recipe_id": cursor.lastrowid})
@app.route('/api/recipes', methods=['GET'])
@login_required
def get_saved_recipes():
"""์ ์ฅ๋ ๋ ์ํผ ๋ชฉ๋ก"""
db = get_db()
recipes = db.execute('''
SELECT * FROM saved_recipes
WHERE user_id = ?
ORDER BY created_at DESC
''', (session['user_id'],)).fetchall()
result = []
for r in recipes:
result.append({
"id": r['id'],
"name": r['recipe_name'],
"recipe": json.loads(r['recipe_data']),
"ingredients": json.loads(r['ingredients']),
"cuisine_type": r['cuisine_type'],
"difficulty": r['difficulty'],
"cook_time": r['cook_time'],
"rating": r['rating'],
"notes": r['notes'],
"tags": json.loads(r['tags']),
"created_at": r['created_at']
})
return jsonify({"success": True, "recipes": result})
@app.route('/api/recipes/<int:recipe_id>', methods=['GET'])
@login_required
def get_recipe_detail(recipe_id):
"""๋ ์ํผ ์์ธ"""
db = get_db()
r = db.execute(
'SELECT * FROM saved_recipes WHERE id = ? AND user_id = ?',
(recipe_id, session['user_id'])
).fetchone()
if not r:
return jsonify({"success": False, "error": "๋ ์ํผ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค"}), 404
return jsonify({
"success": True,
"recipe": {
"id": r['id'],
"name": r['recipe_name'],
"recipe": json.loads(r['recipe_data']),
"ingredients": json.loads(r['ingredients']),
"cuisine_type": r['cuisine_type'],
"difficulty": r['difficulty'],
"cook_time": r['cook_time'],
"rating": r['rating'],
"notes": r['notes'],
"tags": json.loads(r['tags']),
"created_at": r['created_at']
}
})
@app.route('/api/recipes/<int:recipe_id>', methods=['PUT'])
@login_required
def update_recipe(recipe_id):
"""๋ ์ํผ ์์ (๋ฉ๋ชจ, ํ์ )"""
data = request.get_json()
db = get_db()
r = db.execute(
'SELECT id FROM saved_recipes WHERE id = ? AND user_id = ?',
(recipe_id, session['user_id'])
).fetchone()
if not r:
return jsonify({"success": False, "error": "๋ ์ํผ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค"}), 404
# ํ์ดํธ๋ฆฌ์คํธ ๊ธฐ๋ฐ ์
๋ฐ์ดํธ (SQL Injection ๋ฐฉ์ง)
ALLOWED_FIELDS = {'rating', 'notes', 'tags'}
updates = []
params = []
if 'rating' in data and 'rating' in ALLOWED_FIELDS:
rating = data['rating']
if isinstance(rating, int) and 1 <= rating <= 5:
updates.append('rating = ?')
params.append(rating)
if 'notes' in data and 'notes' in ALLOWED_FIELDS:
notes = data['notes']
if isinstance(notes, str) and len(notes) <= 1000:
updates.append('notes = ?')
params.append(notes)
if 'tags' in data and 'tags' in ALLOWED_FIELDS:
tags = data['tags']
if isinstance(tags, list):
updates.append('tags = ?')
params.append(json.dumps(tags))
if updates:
params.append(recipe_id)
# ์์ ํ ์ ์ ์ฟผ๋ฆฌ ๊ตฌ์ฑ (ํ์ดํธ๋ฆฌ์คํธ ํ๋๋ง ์ฌ์ฉ)
query = 'UPDATE saved_recipes SET ' + ', '.join(updates) + ' WHERE id = ?'
db.execute(query, params)
db.commit()
return jsonify({"success": True})
@app.route('/api/recipes/<int:recipe_id>', methods=['DELETE'])
@login_required
def delete_recipe(recipe_id):
"""๋ ์ํผ ์ญ์ """
db = get_db()
r = db.execute(
'SELECT id FROM saved_recipes WHERE id = ? AND user_id = ?',
(recipe_id, session['user_id'])
).fetchone()
if not r:
return jsonify({"success": False, "error": "๋ ์ํผ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค"}), 404
db.execute('DELETE FROM saved_recipes WHERE id = ?', (recipe_id,))
db.commit()
return jsonify({"success": True})
# ===== Step 3: ํ์คํ ๋ฆฌ API =====
@app.route('/api/history/analysis', methods=['GET'])
@login_required
def get_analysis_history():
"""๋ถ์ ํ์คํ ๋ฆฌ"""
db = get_db()
history = db.execute('''
SELECT * FROM analysis_history
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 20
''', (session['user_id'],)).fetchall()
result = []
for h in history:
result.append({
"id": h['id'],
"ingredients": json.loads(h['detected_ingredients']),
"created_at": h['created_at']
})
return jsonify({"success": True, "history": result})
@app.route('/api/history/stats', methods=['GET'])
@login_required
def get_stats():
"""ํต๊ณ ์ ๋ณด"""
db = get_db()
# ์ ์ฅ๋ ๋ ์ํผ ์
recipe_count = db.execute(
'SELECT COUNT(*) as count FROM saved_recipes WHERE user_id = ?',
(session['user_id'],)
).fetchone()['count']
# ๋ถ์ ํ์
analysis_count = db.execute(
'SELECT COUNT(*) as count FROM analysis_history WHERE user_id = ?',
(session['user_id'],)
).fetchone()['count']
# ์์ฃผ ์ฌ์ฉํ๋ ์ฌ๋ฃ (SQLite JSON1 extension์ผ๋ก DB์์ ์ง์ ์ง๊ณ)
# json_each()๋ฅผ ์ฌ์ฉํ์ฌ JSON ๋ฐฐ์ด์ ํ์ผ๋ก ํ์ฅํ๊ณ GROUP BY๋ก ์ง๊ณ
top_ingredients_rows = db.execute('''
SELECT ingredient.value as name, COUNT(*) as count
FROM analysis_history, json_each(detected_ingredients) as ingredient
WHERE user_id = ?
GROUP BY ingredient.value
ORDER BY count DESC
LIMIT 10
''', (session['user_id'],)).fetchall()
top_ingredients = [(row['name'], row['count']) for row in top_ingredients_rows]
return jsonify({
"success": True,
"stats": {
"saved_recipes": recipe_count,
"analysis_count": analysis_count,
"top_ingredients": [{"name": k, "count": v} for k, v in top_ingredients]
}
})
# ===== ์ง๋จ ์๋ํฌ์ธํธ (๊ฐ๋ฐ์ฉ) =====
@app.route('/api/debug/test-api', methods=['GET'])
def test_api():
"""API ํค ๋ฐ ๋ชจ๋ธ ์ฐ๊ฒฐ ํ
์คํธ"""
if os.getenv('FLASK_ENV') == 'production':
return jsonify({"error": "Not available in production"}), 403
results = []
key_preview = f"{OPENROUTER_API_KEY[:15]}...{OPENROUTER_API_KEY[-4:]}" if OPENROUTER_API_KEY else "NOT SET"
# ๊ฐ๋จํ ํ
์คํธ ์์ฒญ์ผ๋ก API ํค ์ ํจ์ฑ ํ
์คํธ
test_messages = [{"role": "user", "content": "Say hello in Korean, one word only"}]
for model in IMAGE_MODELS[:2]: # ์ฒ์ 2๊ฐ ๋ชจ๋ธ๋ง ํ
์คํธ
result = call_openrouter(model, test_messages, timeout=15)
results.append({
"model": model,
"success": "error" not in result,
"response": result.get('choices', [{}])[0].get('message', {}).get('content', '')[:100] if "error" not in result else str(result.get('error', ''))[:200]
})
return jsonify({
"api_key": key_preview,
"image_models": IMAGE_MODELS,
"text_models": TEXT_MODELS,
"test_results": results
})
if __name__ == '__main__':
# ํ๋ก๋์
์์๋ FLASK_ENV=production์ผ๋ก ์ค์
debug_mode = os.getenv('FLASK_ENV', 'development') == 'development'
app.run(debug=debug_mode, port=5000)
