Files
food-ai/backend/internal/gemini/menu.go
dbastrikin ea8e207a45 feat: implement Iteration 4 — menu planning, shopping list, diary
Backend:
- Migrations 007 (menu_plans, menu_items, shopping_lists) and
  008 (meal_diary)
- gemini/menu.go: GenerateMenu — 7-day × 3-meal plan via one Groq call
- internal/menu: model, repository (GetByWeek, SaveMenuInTx,
  shopping list CRUD), handler (GET/PUT/DELETE /menu,
  POST /ai/generate-menu, shopping list endpoints)
- internal/diary: model, repository, handler (GET/POST/DELETE /diary)
- Increase server WriteTimeout to 120s for long AI calls
- api_client.go: add patch() and postList() helpers

Flutter:
- shared/models: menu.dart, shopping_item.dart, diary_entry.dart
- features/menu: menu_service.dart, menu_provider.dart
  (MenuNotifier, ShoppingListNotifier, DiaryNotifier with family)
- MenuScreen: 7-day view, week nav, skeleton on generation,
  generate FAB with confirmation dialog
- ShoppingListScreen: items by category, optimistic checkbox toggle
- DiaryScreen: daily entries with swipe-to-delete, add-entry sheet
- Router: /menu/shopping-list and /menu/diary routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 12:00:25 +02:00

169 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gemini
import (
"context"
"fmt"
"strings"
)
// MenuRequest contains parameters for weekly menu generation.
type MenuRequest struct {
UserGoal string
DailyCalories int
Restrictions []string
CuisinePrefs []string
AvailableProducts []string
}
// DayPlan is the AI-generated plan for a single day.
type DayPlan struct {
Day int `json:"day"`
Meals []MealEntry `json:"meals"`
}
// MealEntry is a single meal within a day plan.
type MealEntry struct {
MealType string `json:"meal_type"` // breakfast | lunch | dinner
Recipe Recipe `json:"recipe"`
}
// GenerateMenu asks the model to plan 7 days × 3 meals.
// Returns exactly 7 DayPlan items on success.
func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan, error) {
prompt := buildMenuPrompt(req)
messages := []map[string]string{
{"role": "user", "content": prompt},
}
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
messages = append(messages, map[string]string{
"role": "user",
"content": "Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО объект JSON без какого-либо текста до или после.",
})
}
text, err := c.generateContent(ctx, messages)
if err != nil {
return nil, err
}
days, err := parseMenuJSON(text)
if err != nil {
lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err)
continue
}
for i := range days {
for j := range days[i].Meals {
days[i].Meals[j].Recipe.Nutrition.Approximate = true
}
}
return days, nil
}
return nil, fmt.Errorf("failed to parse valid JSON after %d attempts: %w", maxRetries, lastErr)
}
func buildMenuPrompt(req MenuRequest) string {
goalRu := map[string]string{
"weight_loss": "похудение",
"maintain": "поддержание веса",
"gain": "набор массы",
}
goal := goalRu[req.UserGoal]
if goal == "" {
goal = "поддержание веса"
}
restrictions := "нет"
if len(req.Restrictions) > 0 {
restrictions = strings.Join(req.Restrictions, ", ")
}
cuisines := "любые"
if len(req.CuisinePrefs) > 0 {
cuisines = strings.Join(req.CuisinePrefs, ", ")
}
calories := req.DailyCalories
if calories <= 0 {
calories = 2000
}
productsSection := ""
if len(req.AvailableProducts) > 0 {
productsSection = "\nПродукты в наличии (приоритет — скоро истекают ⚠):\n" +
strings.Join(req.AvailableProducts, "\n") +
"\nПо возможности используй эти продукты.\n"
}
return fmt.Sprintf(`Ты — диетолог-повар. Составь меню на 7 дней на русском языке.
Профиль пользователя:
- Цель: %s
- Дневная норма калорий: %d ккал (завтрак 25%%, обед 40%%, ужин 35%%)
- Ограничения: %s
- Предпочтения кухни: %s
%s
Требования:
- 3 приёма пищи в день: breakfast, lunch, dinner
- Не повторять рецепты
- КБЖУ на 1 порцию (приблизительно)
- Поле image_query — ТОЛЬКО на английском языке (для поиска фото)
Верни ТОЛЬКО валидный JSON без markdown:
{
"days": [
{
"day": 1,
"meals": [
{
"meal_type": "breakfast",
"recipe": {
"title": "Название",
"description": "2-3 предложения",
"cuisine": "russian|asian|european|mediterranean|american|other",
"difficulty": "easy|medium|hard",
"prep_time_min": 5,
"cook_time_min": 10,
"servings": 1,
"image_query": "oatmeal apple breakfast bowl",
"ingredients": [{"name": "Овсянка", "amount": 80, "unit": "г"}],
"steps": [{"number": 1, "description": "...", "timer_seconds": null}],
"tags": ["быстрый завтрак"],
"nutrition_per_serving": {
"calories": 320, "protein_g": 8, "fat_g": 6, "carbs_g": 58
}
}
},
{"meal_type": "lunch", "recipe": {...}},
{"meal_type": "dinner", "recipe": {...}}
]
}
]
}`, goal, calories, restrictions, cuisines, productsSection)
}
func parseMenuJSON(text string) ([]DayPlan, error) {
text = strings.TrimSpace(text)
if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```json")
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
}
var wrapper struct {
Days []DayPlan `json:"days"`
}
if err := parseJSON(text, &wrapper); err != nil {
return nil, err
}
if len(wrapper.Days) == 0 {
return nil, fmt.Errorf("empty days array in response")
}
return wrapper.Days, nil
}