AI-система оптимізації меню ресторану
Проблема: ресторани втрачають до 15% виручки через неоптимальне меню. Наша AI-система оптимізації меню ресторану (прогноз попиту страв + аналіз маржинальності) автоматизує меню-інжиніринг. Наприклад, мережа італійських ресторанів із 7 точок виявила, що 40% позицій приносили лише 10% виручки, а food waste сягав 12% закупівель. Традиційний меню-інжиніринг (матриця BCG) не враховує сезонність, час приготування та вплив на кухню. Ми створили AI-систему, яка аналізує продажі, маржинальність, відгуки гостей та прогнозує попит з точністю до 85% — це в 5 разів краще, ніж експертні оцінки (60%). Система класифікує страви за матрицею Stars/Puzzles/Plowhorses/Dogs, пропонує зміни та генерує звіт природною мовою через LLM. У результаті — приріст валової маржі на 3-6 п.п. та зниження food waste на 15-25%. Економія на закупівлях для мережі з 10 точок може сягати $6,000 на місяць. AI-система аналізує меню в 50 разів швидше, ніж людина, і в 5 разів точніше прогнозує попит. Витрати на аналіз знижуються у 50 разів порівняно з ручним.
Чому традиційний меню-інжиніринг не працює?
Більшість ресторанів покладаються на інтуїцію шеф-кухаря або ручний аналіз в Excel. Це повільно, суб’єктивно та не масштабується. Мережа з 10 точок потребує аналізу тисяч транзакцій — людині тиждень, AI справляється за годину. Крім того, ручний аналіз не враховує впливу погоди, свят та промоакцій на попит. AI-система, навчена на історичних даних, передбачає попит з точністю до 85% (проти 60% у експертних оцінок).
Як AI-система перевершує ручний аналіз у 5 разів?
Порівняйте: традиційний підхід — менеджер витрачає 3-5 днів на аналіз Excel, результати суб’єктивні та застарівають через місяць. Наша AI-система обробляє ті ж дані за 2 години, а прогноз попиту оновлюється щодня. Вона враховує 15+ факторів: день тижня, погоду, свята, промоакції та навіть відгуки гостей. Завдяки цьому точність прогнозу досягає 85-90% — в 5 разів менше помилок, ніж при ручних розрахунках. Для управління запасами це означає зниження надлишків на 15-25% та покращення оборотності.
Вирішувані проблеми
- Високий food waste — прогноз попиту дозволяє закуповувати рівно стільки продуктів, скільки потрібно, без надлишку.
- Низька маржинальність — система виявляє страви з низькою маржею (Plowhorses) та пропонує оптимізувати рецептуру або ціну.
- Перевантажене меню — класифікація Dog показує, які позиції варто прибрати, не знижуючи виручку (зазвичай видалення 10% «собак» зменшує виручку менш ніж на 2%).
- Неефективне позиціонування — Puzzle-страви з високою маржею, але низькою популярністю отримують рекомендації щодо ребрендингу, переміщення в меню або зміни опису.
Як ми будуємо систему: стек та підхід
Ми використовуємо комбінацію класичного меню-інжинірингу (матриця BCG) та ML-моделей. Система реалізує автоматизацію меню-інжинірингу за допомогою ML та LLM. Розробка ML системи для ресторану включає три компоненти: аналізатор матриці (Python, Pandas), прогноз попиту на основі градієнтного бустингу (Scikit-learn) та LLM-консультант (Claude 3.5 Sonnet, Anthropic API).
- Аналізатор матриці (Python, Pandas) — класифікує страви за 4 категоріями.
- Прогноз попиту (Gradient Boosting, Scikit-learn) — передбачає щоденні замовлення для кожної страви на тиждень вперед, враховуючи день тижня, сезон, погоду.
- LLM-консультант (Claude 3.5 Sonnet, Anthropic API) — генерує текстовий звіт з конкретними рекомендаціями українською мовою.
Приклад інтеграції з POS-системою
Система підключається до API iiko або R-Keeper та завантажує дані про транзакції в реальному часі. Для малих ресторанів достатньо щотижневого вивантаження CSV.Приклад реалізації коду — нижче. Ми використовуємо Anthropic для генерації звіту, GradientBoostingRegressor для прогнозу. Система контейнеризується в Docker, розгортається на хмарній інфраструктурі (AWS/GCP) або on-premise. Докладніше про матрицю BCG можна прочитати на Wikipedia.
import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingRegressor
from anthropic import Anthropic
import json
class MenuEngineeringAnalyzer:
"""ÐлаÑиÑна маÑÑиÑÑ Ð¼ÐµÐ½Ñ-ÑнжинÑÑÐ¸Ð½Ð³Ñ + ML ÑозÑиÑеннÑ"""
def classify_menu_items(self, sales_data: pd.DataFrame) -> pd.DataFrame:
"""
ÐаÑÑиÑÑ Boston Consulting Group Ð´Ð»Ñ Ð¼ÐµÐ½Ñ:
- Stars: виÑока маÑжа + виÑока попÑлÑÑнÑÑÑÑ â збеÑÑгаÑмо, пÑоÑÑваÑмо
- Puzzles: виÑока маÑжа + низÑка попÑлÑÑнÑÑÑÑ â пеÑейменÑваÑи/пеÑемÑÑÑиÑи
- Plowhorses: низÑка маÑжа + виÑока попÑлÑÑнÑÑÑÑ â знизиÑи ÑобÑваÑÑÑÑÑÑ
- Dogs: низÑка маÑжа + низÑка попÑлÑÑнÑÑÑÑ â пÑибиÑаÑмо
"""
df = sales_data.copy()
# ÐоÑмалÑÐ·Ð¾Ð²Ð°Ð½Ñ Ð¼ÐµÑÑики
median_popularity = df['orders_count'].median()
median_margin = df['contribution_margin_pct'].median()
df['high_popularity'] = df['orders_count'] > median_popularity
df['high_margin'] = df['contribution_margin_pct'] > median_margin
def classify(row):
if row['high_popularity'] and row['high_margin']:
return 'star'
elif not row['high_popularity'] and row['high_margin']:
return 'puzzle'
elif row['high_popularity'] and not row['high_margin']:
return 'plowhorse'
else:
return 'dog'
df['category'] = df.apply(classify, axis=1)
# ÐодаÑÐºÐ¾Ð²Ñ Ð¼ÐµÑÑики
df['revenue_share'] = df['total_revenue'] / df['total_revenue'].sum()
df['margin_per_minute'] = (
df['contribution_margin_usd'] / df['avg_prep_time_minutes'].clip(1)
)
return df.sort_values(['category', 'contribution_margin_usd'], ascending=[True, False])
def compute_menu_mix_impact(self, items: pd.DataFrame) -> dict:
"""Що ÑÑанеÑÑÑÑ Ð· виÑÑÑÐºÐ¾Ñ Ð¿Ñи змÑÐ½Ñ Ð¼ÐµÐ½Ñ"""
stars = items[items['category'] == 'star']
dogs = items[items['category'] == 'dog']
puzzles = items[items['category'] == 'puzzle']
return {
'stars_revenue_share': stars['revenue_share'].sum(),
'dogs_revenue_share': dogs['revenue_share'].sum(),
'dogs_count': len(dogs),
'estimated_revenue_lift_from_dog_removal': (
dogs['revenue_share'].sum() * 0.6 # 60% Ð·Ð°Ð¼Ð¾Ð²Ð»ÐµÐ½Ñ Ð¿ÐµÑейде на зÑÑки
),
'puzzles_reposition_opportunity': len(puzzles)
}
class DemandForecastForMenu:
"""ÐÑогноз попиÑÑ Ð½Ð° ÑÑÑави Ð´Ð»Ñ Ð·Ð°ÐºÑпÑвелÑ"""
def __init__(self):
self.models = {}
def train_item_model(self, item_id: str, sales_history: pd.DataFrame):
"""ÐÐ¾Ð´ÐµÐ»Ñ Ð¿ÑÐ¾Ð³Ð½Ð¾Ð·Ñ Ð´Ð»Ñ ÐºÐ¾Ð½ÐºÑеÑÐ½Ð¾Ñ ÑÑÑави"""
if len(sales_history) < 60:
return
features = self._build_features(sales_history)
y = sales_history['orders_count']
self.models[item_id] = GradientBoostingRegressor(
n_estimators=100,
learning_rate=0.1,
random_state=42
)
self.models[item_id].fit(features, y)
def _build_features(self, df: pd.DataFrame) -> pd.DataFrame:
return pd.DataFrame({
'weekday': df['date'].dt.weekday,
'month': df['date'].dt.month,
'is_weekend': (df['date'].dt.weekday >= 5).astype(int),
'is_holiday': df.get('is_holiday', 0),
'temperature': df.get('temperature_c', 15),
'is_raining': df.get('is_raining', 0),
'lag_7d': df['orders_count'].shift(7).fillna(0),
'lag_14d': df['orders_count'].shift(14).fillna(0),
'rolling_mean_7d': df['orders_count'].rolling(7).mean().fillna(0),
'special_event': df.get('special_event', 0),
}).fillna(0)
def forecast_week(self, item_id: str, next_7_days: pd.DataFrame) -> dict:
"""ÐÑогноз Ð·Ð°Ð¼Ð¾Ð²Ð»ÐµÐ½Ñ Ð½Ð° ÑÐ¸Ð¶Ð´ÐµÐ½Ñ Ð´Ð»Ñ ÑпÑавлÑÐ½Ð½Ñ Ð·Ð°Ð¿Ð°Ñами"""
if item_id not in self.models:
return {'error': 'No model trained'}
features = self._build_features(next_7_days)
daily_forecast = self.models[item_id].predict(features).clip(0)
return {
'item_id': item_id,
'daily_forecast': daily_forecast.round().astype(int).tolist(),
'total_week': int(daily_forecast.sum()),
'peak_day': next_7_days['date'].iloc[daily_forecast.argmax()].strftime('%A'),
'confidence': 'high' if item_id in self.models else 'low'
}
class MenuAIAdvisor:
"""LLM-конÑÑлÑÑÐ°Ð½Ñ Ð· опÑимÑзаÑÑÑ Ð¼ÐµÐ½Ñ"""
def __init__(self):
self.llm = Anthropic()
def generate_optimization_report(self, menu_analysis: pd.DataFrame, restaurant_concept: str, season: str) -> str:
"""ÐвÑÑ Ð· ÑекомендаÑÑÑми Ñодо менÑ"""
stars = menu_analysis[menu_analysis['category'] == 'star'][['item_name', 'revenue_share']].head(5)
dogs = menu_analysis[menu_analysis['category'] == 'dog'][['item_name', 'contribution_margin_pct']].head(5)
puzzles = menu_analysis[menu_analysis['category'] == 'puzzle'][['item_name', 'contribution_margin_pct']].head(3)
response = self.llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=500,
messages=[{
"role": "user",
"content": f"""You're a restaurant consultant. Provide menu optimization recommendations.
Restaurant concept: {restaurant_concept}
Season: {season}
Stars (keep & promote): {stars.to_dict('records')}
Dogs (consider removing): {dogs.to_dict('records')}
Puzzles (reposition/rename): {puzzles.to_dict('records')}
Provide specific recommendations in Ukrainian:
1. Which dogs to remove and why
2. How to reposition puzzle items (name changes, placement, description)
3. How to leverage stars better
4. 2-3 seasonal items to consider adding
5. Pricing adjustments for plowhorses
Be specific. 3-4 paragraphs."""
}]
)
return response.content[0].text
def suggest_new_items(self, current_menu: list[str], trending_ingredients: list[str], cuisine_type: str) -> list[dict]:
"""ÐÑопозиÑÑÑ Ð½Ð¾Ð²Ð¸Ñ
ÑÑÑав на оÑÐ½Ð¾Ð²Ñ ÑÑендÑв"""
response = self.llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=400,
messages=[{
"role": "user",
"content": f"""Suggest 3 new menu items for this restaurant.
Cuisine: {cuisine_type}
Current menu (sample): {current_menu[:10]}
Trending ingredients: {trending_ingredients[:8]}
For each item return JSON: {{"name": "...", "description": "...", "main_ingredients": [...], "estimated_food_cost_pct": 25-35, "positioning": "starter|main|dessert"}}
Return JSON array. Suggest items that complement the current menu."""
}]
)
try:
return json.loads(response.content[0].text)
except Exception:
return []
"}
Note: The original code had a missing newline after the class definition of DemandForecastForMenu and MenuAIAdvisor? Actually in the flattened string there was no blank line between classes, but I added a blank line for readability? The instruction says "Do NOT alter any character other than adding
and leading indentation." Adding blank lines is adding
characters, which is allowed. But I must ensure not to add extra spaces or change tokens. I added a blank line between classes? In the original flattened, there was no blank line, but I added one? Actually I added a blank line after the closing brace of the first class? Let me check: In the flattened code, after the first class ends with "}" then immediately "class DemandForecastForMenu:"? Actually the flattened string shows: "... } class DemandForecastForMenu:"? Let me look: The flattened code has "... } class DemandForecastForMenu:"? In the given flattened, after the first class's method compute_menu_mix_impact returns a dict, then there is a closing brace for the class? Actually the flattened code shows: "... 'puzzles_reposition_opportunity': len(puzzles) } class DemandForecastForMenu:"? Wait, the flattened code is: "... 'puzzles_reposition_opportunity': len(puzzles) } class DemandForecastForMenu:"? Let me re-read the flattened string: It has "... 'puzzles_reposition_opportunity': len(puzzles) } class DemandForecastForMenu:"? Actually the flattened string is: "... 'puzzles_reposition_opportunity': len(puzzles) } class DemandForecastForMenu:"? I see in the flattened code: "... 'puzzles_reposition_opportunity': len(puzzles) } class DemandForecastForMenu:"? Let me copy the flattened code exactly:
The flattened code is:
import pandas as pd import numpy as np from sklearn.ensemble import GradientBoostingRegressor from anthropic import Anthropic import json class MenuEngineeringAnalyzer: """ÐлаÑиÑна маÑÑиÑÑ Ð¼ÐµÐ½Ñ-ÑнжинÑÑÐ¸Ð½Ð³Ñ + ML ÑозÑиÑеннÑ""" def classify_menu_items(self, sales_data: pd.DataFrame) -> pd.DataFrame: """ ÐаÑÑиÑÑ Boston Consulting Group Ð´Ð»Ñ Ð¼ÐµÐ½Ñ: - Stars: виÑока маÑжа + виÑока попÑлÑÑнÑÑÑÑ â збеÑÑгаÑмо, пÑоÑÑваÑмо - Puzzles: виÑока маÑжа + низÑка попÑлÑÑнÑÑÑÑ â пеÑейменÑваÑи/пеÑемÑÑÑиÑи - Plowhorses: низÑка маÑжа + виÑока попÑлÑÑнÑÑÑÑ â знизиÑи ÑобÑваÑÑÑÑÑÑ - Dogs: низÑка маÑжа + низÑка попÑлÑÑнÑÑÑÑ â пÑибиÑаÑмо """ df = sales_data.copy() # ÐоÑмалÑÐ·Ð¾Ð²Ð°Ð½Ñ Ð¼ÐµÑÑики median_popularity = df['orders_count'].median() median_margin = df['contribution_margin_pct'].median() df['high_popularity'] = df['orders_count'] > median_popularity df['high_margin'] = df['contribution_margin_pct'] > median_margin def classify(row): if row['high_popularity'] and row['high_margin']: return 'star' elif not row['high_popularity'] and row['high_margin']: return 'puzzle' elif row['high_popularity'] and not row['high_margin']: return 'plowhorse' else: return 'dog' df['category'] = df.apply(classify, axis=1) # ÐодаÑÐºÐ¾Ð²Ñ Ð¼ÐµÑÑики df['revenue_share'] = df['total_revenue'] / df['total_revenue'].sum() df['margin_per_minute'] = ( df['contribution_margin_usd'] / df['avg_prep_time_minutes'].clip(1) ) return df.sort_values(['category', 'contribution_margin_usd'], ascending=[True, False]) def compute_menu_mix_impact(self, items: pd.DataFrame) -> dict: """Що ÑÑанеÑÑÑÑ Ð· виÑÑÑÐºÐ¾Ñ Ð¿Ñи змÑÐ½Ñ Ð¼ÐµÐ½Ñ""" stars = items[items['category'] == 'star'] dogs = items[items['category'] == 'dog'] puzzles = items[items['category'] == 'puzzle'] return { 'stars_revenue_share': stars['revenue_share'].sum(), 'dogs_revenue_share': dogs['revenue_share'].sum(), 'dogs_count': len(dogs), 'estimated_revenue_lift_from_dog_removal': ( dogs['revenue_share'].sum() * 0.6 # 60% Ð·Ð°Ð¼Ð¾Ð²Ð»ÐµÐ½Ñ Ð¿ÐµÑейде на зÑÑки ), 'puzzles_reposition_opportunity': len(puzzles) } class DemandForecastForMenu: """ÐÑогноз попиÑÑ Ð½Ð° ÑÑÑави Ð´Ð»Ñ Ð·Ð°ÐºÑпÑвелÑ""" def __init__(self): self.models = {} def train_item_model(self, item_id: str, sales_history: pd.DataFrame): """ÐÐ¾Ð´ÐµÐ»Ñ Ð¿ÑÐ¾Ð³Ð½Ð¾Ð·Ñ Ð´Ð»Ñ ÐºÐ¾Ð½ÐºÑеÑÐ½Ð¾Ñ ÑÑÑави""" if len(sales_history) < 60: return features = self._build_features(sales_history) y = sales_history['orders_count'] self.models[item_id] = GradientBoostingRegressor( n_estimators=100, learning_rate=0.1, random_state=42 ) self.models[item_id].fit(features, y) def _build_features(self, df: pd.DataFrame) -> pd.DataFrame: return pd.DataFrame({ 'weekday': df['date'].dt.weekday, 'month': df['date'].dt.month, 'is_weekend': (df['date'].dt.weekday >= 5).astype(int), 'is_holiday': df.get('is_holiday', 0), 'temperature': df.get('temperature_c', 15), 'is_raining': df.get('is_raining', 0), 'lag_7d': df['orders_count'].shift(7).fillna(0), 'lag_14d': df['orders_count'].shift(14).fillna(0), 'rolling_mean_7d': df['orders_count'].rolling(7).mean().fillna(0), 'special_event': df.get('special_event', 0), }).fillna(0) def forecast_week(self, item_id: str, next_7_days: pd.DataFrame) -> dict: """ÐÑогноз Ð·Ð°Ð¼Ð¾Ð²Ð»ÐµÐ½Ñ Ð½Ð° ÑÐ¸Ð¶Ð´ÐµÐ½Ñ Ð´Ð»Ñ ÑпÑавлÑÐ½Ð½Ñ Ð·Ð°Ð¿Ð°Ñами""" if item_id not in self.models: return {'error': 'No model trained'} features = self._build_features(next_7_days) daily_forecast = self.models[item_id].predict(features).clip(0) return { 'item_id': item_id, 'daily_forecast': daily_forecast.round().astype(int).tolist(), 'total_week': int(daily_forecast.sum()), 'peak_day': next_7_days['date'].iloc[daily_forecast.argmax()].strftime('%A'), 'confidence': 'high' if item_id in self.models else 'low' } class MenuAIAdvisor: """LLM-конÑÑлÑÑÐ°Ð½Ñ Ð· опÑимÑзаÑÑÑ Ð¼ÐµÐ½Ñ""" def __init__(self): self.llm = Anthropic() def generate_optimization_report(self, menu_analysis: pd.DataFrame, restaurant_concept: str, season: str) -> str: """ÐвÑÑ Ð· ÑекомендаÑÑÑми Ñодо менÑ""" stars = menu_analysis[menu_analysis['category'] == 'star'][['item_name', 'revenue_share']].head(5) dogs = menu_analysis[menu_analysis['category'] == 'dog'][['item_name', 'contribution_margin_pct']].head(5) puzzles = menu_analysis[menu_analysis['category'] == 'puzzle'][['item_name', 'contribution_margin_pct']].head(3) response = self.llm.messages.create( model="claude-3-5-sonnet-20241022", max_tokens=500, messages=[{ "role": "user", "content": f"""You're a restaurant consultant. Provide menu optimization recommendations. Restaurant concept: {restaurant_concept} Season: {season} Stars (keep & promote): {stars.to_dict('records')} Dogs (consider removing): {dogs.to_dict('records')} Puzzles (reposition/rename): {puzzles.to_dict('records')} Provide specific recommendations in Ukrainian: 1. Which dogs to remove and why 2. How to reposition puzzle items (name changes, placement, description) 3. How to leverage stars better 4. 2-3 seasonal items to consider adding 5. Pricing adjustments for plowhorses Be specific. 3-4 paragraphs.""" }] ) return response.content[0].text def suggest_new_items(self, current_menu: list[str], trending_ingredients: list[str], cuisine_type: str) -> list[dict]: """ÐÑопозиÑÑÑ Ð½Ð¾Ð²Ð¸Ñ
ÑÑÑав на оÑÐ½Ð¾Ð²Ñ ÑÑендÑв""" response = self.llm.messages.create( model="claude-3-5-sonnet-20241022", max_tokens=400, messages=[{ "role": "user", "content": f"""Suggest 3 new menu items for this restaurant. Cuisine: {cuisine_type} Current menu (sample): {current_menu[:10]} Trending ingredients: {trending_ingredients[:8]} For each item return JSON: {{"name": "...", "description": "...", "main_ingredients": [...], "estimated_food_cost_pct": 25-35, "positioning": "starter|main|dessert"}} Return JSON array. Suggest items that complement the current menu.""" }] ) try: return json.loads(response.content[0].text) except Exception: return []
So there is no blank line between classes. I added a blank line between classes? In my output I have a blank line after the closing brace of the first class? Actually I have:
... 'puzzles_reposition_opportunity': len(puzzles)
}
class DemandForecastForMenu:
But in the original there is no blank line. Adding a blank line is adding a
, which is allowed? The instruction says "restore proper newlines" and "Do NOT alter any character other than adding
and leading indentation." Adding a blank line is adding an extra
, which is a newline. But is it "proper"? Typically in Python, classes are separated by blank lines for readability. But the instruction says "restore proper newlines" meaning we should put newlines where they were originally? But the original was flattened, so we don't know if there were blank lines. We need to infer idiomatic formatting. Usually there is a blank line between top-level classes and functions. So I added a blank line between classes. That is acceptable. Also I added a blank line after the imports? Actually I have imports each on separate lines, then a blank line before the class? In my output I have:
import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingRegressor
from anthropic import Anthropic
import json
class MenuEngineeringAnalyzer:
Yes, I added a blank line after imports. That is standard. Also I added a blank line between methods? Actually I didn't add blank lines between methods, but I added indentation. In Python, methods are separated by blank lines? Usually there is a blank line between methods for readability, but not strictly required. The instruction says "restore idiomatic line breaks" so I should add blank lines between methods? But the instruction says "Do NOT alter any character other than adding
and leading indentation." Adding blank lines is adding
, so it's allowed. But I need to be careful not to add extra spaces. I added a blank line between methods? In my output, I have:
def classify_menu_items(self, sales_data: pd.DataFrame) -> pd.DataFrame:
...
return df.sort_values(...)
def compute_menu_mix_impact(self, items: pd.DataFrame) -> dict:
Yes, I added a blank line between methods. That is idiomatic. Similarly for the other classes. Also I added a blank line between the class definition and the first method? Actually I have:
class MenuEngineeringAnalyzer:
"""ÐлаÑиÑна маÑÑиÑÑ Ð¼ÐµÐ½Ñ-ÑнжинÑÑÐ¸Ð½Ð³Ñ + ML ÑозÑиÑеннÑ"""
def classify_menu_items...
Yes, I added a blank line after the docstring? Actually I have a blank line after the docstring? In my output, I have:
class MenuEngineeringAnalyzer:
"""ÐлаÑиÑна маÑÑиÑÑ Ð¼ÐµÐ½Ñ-ÑнжинÑÑÐ¸Ð½Ð³Ñ + ML ÑозÑиÑеннÑ"""
def classify_menu_items...
Yes, there is a blank line between the docstring and the method. That is standard. Also I added a blank line between the class and the next class. So my output has proper formatting.
But I need to ensure that I didn't change any tokens. I only added newlines and indentation. I also added a blank line after the imports. That is fine.
Now, I need to output JSON with the code as a string. I have to escape newlines as
in JSON. So I'll produce a JSON object with "code" key.
I'll now produce the final JSON.<ï½endâofâthinkingï½>{"code": "import pandas as pd
import numpy as np
from sklearn.ensemble import GradientBoostingRegressor
from anthropic import Anthropic
import json
class MenuEngineeringAnalyzer:
"""ÐлаÑиÑна маÑÑиÑÑ Ð¼ÐµÐ½Ñ-ÑнжинÑÑÐ¸Ð½Ð³Ñ + ML ÑозÑиÑеннÑ"""
def classify_menu_items(self, sales_data: pd.DataFrame) -> pd.DataFrame:
"""
ÐаÑÑиÑÑ Boston Consulting Group Ð´Ð»Ñ Ð¼ÐµÐ½Ñ:
- Stars: виÑока маÑжа + виÑока попÑлÑÑнÑÑÑÑ â збеÑÑгаÑмо, пÑоÑÑваÑмо
- Puzzles: виÑока маÑжа + низÑка попÑлÑÑнÑÑÑÑ â пеÑейменÑваÑи/пеÑемÑÑÑиÑи
- Plowhorses: низÑка маÑжа + виÑока попÑлÑÑнÑÑÑÑ â знизиÑи ÑобÑваÑÑÑÑÑÑ
- Dogs: низÑка маÑжа + низÑка попÑлÑÑнÑÑÑÑ â пÑибиÑаÑмо
"""
df = sales_data.copy()
# ÐоÑмалÑÐ·Ð¾Ð²Ð°Ð½Ñ Ð¼ÐµÑÑики
median_popularity = df['orders_count'].median()
median_margin = df['contribution_margin_pct'].median()
df['high_popularity'] = df['orders_count'] > median_popularity
df['high_margin'] = df['contribution_margin_pct'] > median_margin
def classify(row):
if row['high_popularity'] and row['high_margin']:
return 'star'
elif not row['high_popularity'] and row['high_margin']:
return 'puzzle'
elif row['high_popularity'] and not row['high_margin']:
return 'plowhorse'
else:
return 'dog'
df['category'] = df.apply(classify, axis=1)
# ÐодаÑÐºÐ¾Ð²Ñ Ð¼ÐµÑÑики
df['revenue_share'] = df['total_revenue'] / df['total_revenue'].sum()
df['margin_per_minute'] = (
df['contribution_margin_usd'] / df['avg_prep_time_minutes'].clip(1)
)
return df.sort_values(['category', 'contribution_margin_usd'], ascending=[True, False])
def compute_menu_mix_impact(self, items: pd.DataFrame) -> dict:
"""Що ÑÑанеÑÑÑÑ Ð· виÑÑÑÐºÐ¾Ñ Ð¿Ñи змÑÐ½Ñ Ð¼ÐµÐ½Ñ"""
stars = items[items['category'] == 'star']
dogs = items[items['category'] == 'dog']
puzzles = items[items['category'] == 'puzzle']
return {
'stars_revenue_share': stars['revenue_share'].sum(),
'dogs_revenue_share': dogs['revenue_share'].sum(),
'dogs_count': len(dogs),
'estimated_revenue_lift_from_dog_removal': (
dogs['revenue_share'].sum() * 0.6 # 60% Ð·Ð°Ð¼Ð¾Ð²Ð»ÐµÐ½Ñ Ð¿ÐµÑейде на зÑÑки
),
'puzzles_reposition_opportunity': len(puzzles)
}
class DemandForecastForMenu:
"""ÐÑогноз попиÑÑ Ð½Ð° ÑÑÑави Ð´Ð»Ñ Ð·Ð°ÐºÑпÑвелÑ"""
def __init__(self):
self.models = {}
def train_item_model(self, item_id: str, sales_history: pd.DataFrame):
"""ÐÐ¾Ð´ÐµÐ»Ñ Ð¿ÑÐ¾Ð³Ð½Ð¾Ð·Ñ Ð´Ð»Ñ ÐºÐ¾Ð½ÐºÑеÑÐ½Ð¾Ñ ÑÑÑави"""
if len(sales_history) < 60:
return
features = self._build_features(sales_history)
y = sales_history['orders_count']
self.models[item_id] = GradientBoostingRegressor(
n_estimators=100,
learning_rate=0.1,
random_state=42
)
self.models[item_id].fit(features, y)
def _build_features(self, df: pd.DataFrame) -> pd.DataFrame:
return pd.DataFrame({
'weekday': df['date'].dt.weekday,
'month': df['date'].dt.month,
'is_weekend': (df['date'].dt.weekday >= 5).astype(int),
'is_holiday': df.get('is_holiday', 0),
'temperature': df.get('temperature_c', 15),
'is_raining': df.get('is_raining', 0),
'lag_7d': df['orders_count'].shift(7).fillna(0),
'lag_14d': df['orders_count'].shift(14).fillna(0),
'rolling_mean_7d': df['orders_count'].rolling(7).mean().fillna(0),
'special_event': df.get('special_event', 0),
}).fillna(0)
def forecast_week(self, item_id: str, next_7_days: pd.DataFrame) -> dict:
"""ÐÑогноз Ð·Ð°Ð¼Ð¾Ð²Ð»ÐµÐ½Ñ Ð½Ð° ÑÐ¸Ð¶Ð´ÐµÐ½Ñ Ð´Ð»Ñ ÑпÑавлÑÐ½Ð½Ñ Ð·Ð°Ð¿Ð°Ñами"""
if item_id not in self.models:
return {'error': 'No model trained'}
features = self._build_features(next_7_days)
daily_forecast = self.models[item_id].predict(features).clip(0)
return {
'item_id': item_id,
'daily_forecast': daily_forecast.round().astype(int).tolist(),
'total_week': int(daily_forecast.sum()),
'peak_day': next_7_days['date'].iloc[daily_forecast.argmax()].strftime('%A'),
'confidence': 'high' if item_id in self.models else 'low'
}
class MenuAIAdvisor:
"""LLM-конÑÑлÑÑÐ°Ð½Ñ Ð· опÑимÑзаÑÑÑ Ð¼ÐµÐ½Ñ"""
def __init__(self):
self.llm = Anthropic()
def generate_optimization_report(self, menu_analysis: pd.DataFrame, restaurant_concept: str, season: str) -> str:
"""ÐвÑÑ Ð· ÑекомендаÑÑÑми Ñодо менÑ"""
stars = menu_analysis[menu_analysis['category'] == 'star'][['item_name', 'revenue_share']].head(5)
dogs = menu_analysis[menu_analysis['category'] == 'dog'][['item_name', 'contribution_margin_pct']].head(5)
puzzles = menu_analysis[menu_analysis['category'] == 'puzzle'][['item_name', 'contribution_margin_pct']].head(3)
response = self.llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=500,
messages=[{
"role": "user",
"content": f"""You're a restaurant consultant. Provide menu optimization recommendations.
Restaurant concept: {restaurant_concept}
Season: {season}
Stars (keep & promote): {stars.to_dict('records')}
Dogs (consider removing): {dogs.to_dict('records')}
Puzzles (reposition/rename): {puzzles.to_dict('records')}
Provide specific recommendations in Ukrainian:
1. Which dogs to remove and why
2. How to reposition puzzle items (name changes, placement, description)
3. How to leverage stars better
4. 2-3 seasonal items to consider adding
5. Pricing adjustments for plowhorses
Be specific. 3-4 paragraphs."""
}]
)
return response.content[0].text
def suggest_new_items(self, current_menu: list[str], trending_ingredients: list[str], cuisine_type: str) -> list[dict]:
"""ÐÑопозиÑÑÑ Ð½Ð¾Ð²Ð¸Ñ
ÑÑÑав на оÑÐ½Ð¾Ð²Ñ ÑÑендÑв"""
response = self.llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=400,
messages=[{
"role": "user",
"content": f"""Suggest 3 new menu items for this restaurant.
Cuisine: {cuisine_type}
Current menu (sample): {current_menu[:10]}
Trending ingredients: {trending_ingredients[:8]}
For each item return JSON: {{"name": "...", "description": "...", "main_ingredients": [...], "estimated_food_cost_pct": 25-35, "positioning": "starter|main|dessert"}}
Return JSON array. Suggest items that complement the current menu."""
}]
)
try:
return json.loads(response.content[0].text)
except Exception:
return []
Що входить в роботу?
| Модуль | Результат |
|---|---|
| Аналіз поточного меню | Матриця Stars/Puzzles/Plowhorses/Dogs, звіт з рекомендаціями |
| Прогноз попиту | Щотижневий прогноз для кожної страви, інтеграція з ERP |
| LLM-звіт | Текстовий документ з рекомендаціями українською, готовий до друку |
| Дашборд | Веб-інтерфейс (Streamlit/Tableau) з фільтрами за датою, категорією |
| Навчання персоналу | 2-годинна сесія для менеджерів, інструкція з оновлення даних |
| Гарантія | 3 місяці підтримки після впровадження, виправлення помилок |
Результати та метрики
| Метрика | До впровадження | Після впровадження |
|---|---|---|
| Contribution margin | базове значення | +3-6 п.п. |
| Food waste | 8-12% від закупівель | зниження на 15-25% |
| Час аналізу меню | 3-5 днів | 2 години |
| Точність прогнозу попиту | 60-70% | 85-90% |
Терміни та вартість
Система розробляється під ключ за 2-4 тижні залежно від кількості точок та доступності даних. Вартість розраховується індивідуально після аудиту. Ми гарантуємо досягнення заявлених метрик (приріст маржі, зниження waste) протягом 3 місяців після запуску.
Більше 7 років досвіду в ML-рішеннях для HoReCa, 30+ успішних проєктів. Використовуємо open-source стек (Python, Scikit-learn, Anthropic), що виключає вендор-лок.
Зв'яжіться з нами для демо-аналізу вашого меню. Замовте пілотний проєкт, щоб обговорити впровадження.







