Skip to content

Commit feceae2

Browse files
author
FoodFlow Bot
committed
Feat: enhance culinary context logic, implement batch KBJU editing, and fix NoneType stability bugs
1 parent 37b4a54 commit feceae2

6 files changed

Lines changed: 359 additions & 51 deletions

File tree

gemini.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Правила работы с FoodFlow Bot
2+
3+
- Все планы реализации (`implementation_plan.md`) и списки задач (`task.md`) должны быть написаны **только на русском языке**.
4+
- Сообщения об обновлениях и отчеты также должны быть на русском.

handlers/i_ate.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from database.base import get_db
1212
from database.models import ConsumptionLog
1313
from services.normalization import NormalizationService
14+
from utils.parsing import safe_float
1415

1516
router = Router()
1617
logger = logging.getLogger(__name__)
@@ -143,11 +144,11 @@ async def i_ate_process(message: types.Message, state: FSMContext) -> None:
143144
result = await NormalizationService.analyze_food_intake(description)
144145

145146
name = result.get("name", description)
146-
calories = float(result.get("calories") or 0)
147-
protein = float(result.get("protein") or 0)
148-
fat = float(result.get("fat") or 0)
149-
carbs = float(result.get("carbs") or 0)
150-
fiber = float(result.get("fiber") or 0)
147+
calories = safe_float(result.get("calories"))
148+
protein = safe_float(result.get("protein"))
149+
fat = safe_float(result.get("fat"))
150+
carbs = safe_float(result.get("carbs"))
151+
fiber = safe_float(result.get("fiber"))
151152
weight_grams = result.get("weight_grams")
152153
weight_missing = result.get("weight_missing", True)
153154
base_name = result.get("base_name")
@@ -163,7 +164,8 @@ async def i_ate_process(message: types.Message, state: FSMContext) -> None:
163164
"protein100": protein,
164165
"fat100": fat,
165166
"carbs100": carbs,
166-
"fiber100": fiber
167+
"fiber100": fiber,
168+
"original_text": description
167169
}
168170
)
169171
await state.set_state(IAteStates.waiting_for_weight)
@@ -189,7 +191,8 @@ async def i_ate_process(message: types.Message, state: FSMContext) -> None:
189191
"protein100": protein,
190192
"fat100": fat,
191193
"carbs100": carbs,
192-
"fiber100": fiber
194+
"fiber100": fiber,
195+
"original_text": description
193196
}
194197
)
195198

@@ -302,8 +305,9 @@ async def show_confirmation_interface(message: types.Message, state: FSMContext,
302305
builder.button(text="🕓 Другое время", callback_data="i_ate_ask_time")
303306
builder.button(text="✏️ Ред. Вес", callback_data="edit_field_weight")
304307
builder.button(text="✏️ Ред. КБЖУ", callback_data="i_ate_edit_macros")
308+
builder.button(text="❌ Это несколько продуктов", callback_data="u_split_to_batch")
305309
builder.button(text="❌ Отмена", callback_data="main_menu")
306-
builder.adjust(2, 2, 1)
310+
builder.adjust(2, 2, 1, 1)
307311

308312
await state.set_state(IAteStates.waiting_for_confirmation)
309313

handlers/universal_input.py

Lines changed: 202 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from services.herbalife_expert import herbalife_expert
3030
from services.normalization import NormalizationService
3131
from services.voice_stt import SpeechToText
32+
from utils.parsing import safe_float
3233

3334
router = Router()
3435
logger = logging.getLogger(__name__)
@@ -43,6 +44,7 @@ class UniversalInputStates(StatesGroup):
4344
batch_confirmation = State() # Viewing batch list
4445
batch_editing = State() # Editing individual item in batch
4546
batch_time_selection = State() # Selecting time for the whole batch
47+
batch_waiting_for_macro_value = State() # Waiting for KBJU value input
4648

4749
# --- HANDLERS ---
4850

@@ -811,11 +813,11 @@ async def process_text_food_logging(
811813
logger.info(f"🍌 Normalization Result for '{text}': {result}")
812814

813815
name = result.get("name", text)
814-
calories = float(result.get("calories") or 0)
815-
protein = float(result.get("protein") or 0)
816-
fat = float(result.get("fat") or 0)
817-
carbs = float(result.get("carbs") or 0)
818-
fiber = float(result.get("fiber") or 0)
816+
calories = safe_float(result.get("calories"))
817+
protein = safe_float(result.get("protein"))
818+
fat = safe_float(result.get("fat"))
819+
carbs = safe_float(result.get("carbs"))
820+
fiber = safe_float(result.get("fiber"))
819821

820822
# --- WATER INTERCEPTION ---
821823
# If the user literally just drank water (or mineral water, hot water)
@@ -1044,11 +1046,11 @@ async def process_text_fridge_add(
10441046
result = await NormalizationService.analyze_food_intake(text)
10451047

10461048
name = result.get("name", text)
1047-
calories = float(result.get("calories") or 0)
1048-
protein = float(result.get("protein") or 0)
1049-
fat = float(result.get("fat") or 0)
1050-
carbs = float(result.get("carbs") or 0)
1051-
fiber = float(result.get("fiber") or 0)
1049+
calories = safe_float(result.get("calories"))
1050+
protein = safe_float(result.get("protein"))
1051+
fat = safe_float(result.get("fat"))
1052+
carbs = safe_float(result.get("carbs"))
1053+
fiber = safe_float(result.get("fiber"))
10521054
weight_grams = weight_override if weight_override else result.get("weight_grams")
10531055

10541056
weight_missing = result.get("weight_missing", True) if not weight_override else False
@@ -1519,11 +1521,11 @@ async def process_batch_food_logging(
15191521
batch_items.append({
15201522
"name": res.get("name", items[i]["product"] if i < len(items) else "?"),
15211523
"base_name": res.get("base_name", res.get("name", "")),
1522-
"calories": float(res.get("calories", 0)),
1523-
"protein": float(res.get("protein", 0)),
1524-
"fat": float(res.get("fat", 0)),
1525-
"carbs": float(res.get("carbs", 0)),
1526-
"fiber": float(res.get("fiber", 0)),
1524+
"calories": safe_float(res.get("calories")),
1525+
"protein": safe_float(res.get("protein")),
1526+
"fat": safe_float(res.get("fat")),
1527+
"carbs": safe_float(res.get("carbs")),
1528+
"fiber": safe_float(res.get("fiber")),
15271529
"weight_grams": res.get("weight_grams"),
15281530
"selected": True # All selected by default
15291531
})
@@ -1577,8 +1579,9 @@ def _render_batch_list(batch_items: list[dict]) -> tuple[str, types.InlineKeyboa
15771579
builder.button(text=f"✅ Прямо сейчас ({selected_count})", callback_data="batch_confirm_now")
15781580
builder.button(text="🕓 Другое время", callback_data="batch_ask_time")
15791581
builder.button(text="✏️ Редактировать", callback_data="batch_edit_start")
1582+
builder.button(text="🥗 Это одно блюдо", callback_data="u_combine_to_single")
15801583
builder.button(text="❌ Отмена", callback_data="batch_cancel")
1581-
builder.adjust(2, 2, 1)
1584+
builder.adjust(2, 2, 1, 1)
15821585

15831586
return text, builder.as_markup()
15841587

@@ -1616,14 +1619,39 @@ def _render_batch_edit_item(batch_items: list[dict], index: int) -> tuple[str, t
16161619
builder.button(text="✅ Вернуть в запись", callback_data=f"batch_toggle:{index}")
16171620

16181621
builder.button(text="🗑️ Удалить совсем", callback_data=f"batch_delete:{index}")
1619-
builder.adjust(2 if nav_buttons else 1, 1, 1)
1622+
builder.button(text="✏️ Редактировать КБЖУ", callback_data=f"batch_item_macros:{index}")
1623+
builder.adjust(2 if nav_buttons else 1, 1, 1, 1)
16201624

16211625
# Back to list
16221626
builder.button(text="🔙 К списку", callback_data="batch_back_to_list")
16231627

16241628
return text, builder.as_markup()
16251629

16261630

1631+
def _render_batch_item_macros_menu(batch_items: list[dict], index: int) -> tuple[str, types.InlineKeyboardMarkup]:
1632+
"""Render the macros selection menu for a single batch item."""
1633+
item = batch_items[index]
1634+
1635+
text = (
1636+
f"<b>✏️ Редактирование: {item['name']}</b>\n\n"
1637+
f"🔥 Ккал: <code>{int(item['calories'])}</code>\n"
1638+
f"🥩 Б: <code>{item['protein']:.1f}</code> | 🥑 Ж: <code>{item['fat']:.1f}</code> | 🍞 У: <code>{item['carbs']:.1f}</code>\n"
1639+
f"🥬 Кл: <code>{item.get('fiber', 0):.1f}</code>\n\n"
1640+
f"Выберите поле для изменения:"
1641+
)
1642+
1643+
builder = InlineKeyboardBuilder()
1644+
builder.button(text="🔥 Ккал", callback_data=f"batch_item_edit_field:calories:{index}")
1645+
builder.button(text="🥩 Бел.", callback_data=f"batch_item_edit_field:protein:{index}")
1646+
builder.button(text="🥑 Жир.", callback_data=f"batch_item_edit_field:fat:{index}")
1647+
builder.button(text="🍞 Угл.", callback_data=f"batch_item_edit_field:carbs:{index}")
1648+
builder.button(text="🥬 Кл.", callback_data=f"batch_item_edit_field:fiber:{index}")
1649+
builder.button(text="🔙 Назад", callback_data=f"batch_back_to_item:{index}")
1650+
builder.adjust(2, 3, 1)
1651+
1652+
return text, builder.as_markup()
1653+
1654+
16271655
# --- BATCH CALLBACKS ---
16281656

16291657
@router.callback_query(F.data == "batch_confirm_now", StateFilter(UniversalInputStates.batch_confirmation, UniversalInputStates.batch_editing))
@@ -1750,6 +1778,81 @@ async def batch_nav(callback: types.CallbackQuery, state: FSMContext) -> None:
17501778
await callback.answer()
17511779

17521780

1781+
@router.callback_query(F.data.startswith("batch_item_macros:"), UniversalInputStates.batch_editing)
1782+
async def batch_item_macros(callback: types.CallbackQuery, state: FSMContext) -> None:
1783+
"""Show macros edit menu for a specific item."""
1784+
index = int(callback.data.split(":")[1])
1785+
data = await state.get_data()
1786+
batch_items = data.get("batch_items", [])
1787+
if not batch_items:
1788+
await callback.answer("Ошибка данных", show_alert=True)
1789+
return
1790+
1791+
text, markup = _render_batch_item_macros_menu(batch_items, index)
1792+
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
1793+
await callback.answer()
1794+
1795+
1796+
@router.callback_query(F.data.startswith("batch_item_edit_field:"), UniversalInputStates.batch_editing)
1797+
async def batch_item_edit_field(callback: types.CallbackQuery, state: FSMContext) -> None:
1798+
"""Ask for a macro value for a specific item."""
1799+
parts = callback.data.split(":")
1800+
field = parts[1]
1801+
index = int(parts[2])
1802+
1803+
await state.update_data(current_batch_edit_field=field, current_batch_edit_index=index)
1804+
await state.set_state(UniversalInputStates.batch_waiting_for_macro_value)
1805+
1806+
labels = {
1807+
"calories": "Калории", "protein": "Белки",
1808+
"fat": "Жиры", "carbs": "Углеводы", "fiber": "Клетчатку"
1809+
}
1810+
1811+
await callback.message.edit_text(f"✏️ Введите новое значение для <b>{labels.get(field)}</b>:", parse_mode="HTML")
1812+
await callback.answer()
1813+
1814+
1815+
@router.callback_query(F.data.startswith("batch_back_to_item:"), UniversalInputStates.batch_editing)
1816+
async def batch_back_to_item(callback: types.CallbackQuery, state: FSMContext) -> None:
1817+
"""Back from macros menu to item info."""
1818+
index = int(callback.data.split(":")[1])
1819+
data = await state.get_data()
1820+
batch_items = data.get("batch_items", [])
1821+
if not batch_items:
1822+
await callback.answer("Ошибка данных", show_alert=True)
1823+
return
1824+
1825+
text, markup = _render_batch_edit_item(batch_items, index)
1826+
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
1827+
await callback.answer()
1828+
1829+
1830+
@router.message(UniversalInputStates.batch_waiting_for_macro_value)
1831+
async def batch_save_macro_value(message: types.Message, state: FSMContext) -> None:
1832+
"""Save the new macro value and return to item view."""
1833+
try:
1834+
value = float(message.text.replace(',', '.').replace('г', '').strip())
1835+
data = await state.get_data()
1836+
field = data.get("current_batch_edit_field")
1837+
index = data.get("current_batch_edit_index")
1838+
batch_items = data.get("batch_items", [])
1839+
1840+
if field and index is not None and index < len(batch_items):
1841+
batch_items[index][field] = value
1842+
await state.update_data(batch_items=batch_items)
1843+
1844+
# Go back to item edit view
1845+
await state.set_state(UniversalInputStates.batch_editing)
1846+
text, markup = _render_batch_edit_item(batch_items, index)
1847+
await message.answer(text, parse_mode="HTML", reply_markup=markup)
1848+
else:
1849+
await message.answer("⚠️ Ошибка контекста. Попробуйте еще раз.")
1850+
await state.set_state(UniversalInputStates.batch_editing)
1851+
1852+
except ValueError:
1853+
await message.answer("⚠️ Пожалуйста, введите корректное число.")
1854+
1855+
17531856
@router.callback_query(F.data.startswith("batch_toggle:"), UniversalInputStates.batch_editing)
17541857
async def batch_toggle(callback: types.CallbackQuery, state: FSMContext) -> None:
17551858
"""Toggle item selection (include/exclude from save)."""
@@ -1821,3 +1924,85 @@ async def batch_confirm_all_from_edit(callback: types.CallbackQuery, state: FSMC
18211924
# It should transition back to the confirmation state and then trigger the time selection flow.
18221925
await state.set_state(UniversalInputStates.batch_confirmation)
18231926
await batch_ask_time(callback, state)
1927+
1928+
1929+
# --- SPLIT / COMBINE HANDLERS ---
1930+
1931+
@router.callback_query(F.data == "u_split_to_batch")
1932+
async def handle_split_to_batch(callback: types.CallbackQuery, state: FSMContext) -> None:
1933+
"""User wants to split a single product into multiple items."""
1934+
data = await state.get_data()
1935+
# Try to find description in different possible places
1936+
description = (data.get("pending_product", {}).get("original_text") or
1937+
data.get("universal_data", {}).get("content") or
1938+
callback.message.text)
1939+
1940+
if not description:
1941+
# Fallback for i_ate context
1942+
if "🍽️" in callback.message.text:
1943+
description = callback.message.text.split("\n")[2].strip().strip("🍽️ ")
1944+
1945+
if not description:
1946+
await callback.answer("❌ Ошибка: не удалось найти исходный текст.", show_alert=True)
1947+
return
1948+
1949+
status_msg = await callback.message.answer("🔄 <b>Разделяю на ингредиенты...</b>", parse_mode="HTML")
1950+
await callback.answer()
1951+
1952+
try:
1953+
# Call AI Brain with force_multi=True
1954+
brain_result = await AIBrainService.analyze_text(description, force_multi=True)
1955+
1956+
if brain_result and isinstance(brain_result, dict) and brain_result.get("multi") and brain_result.get("items"):
1957+
items = brain_result["items"]
1958+
from handlers.universal_input import process_batch_food_logging
1959+
await process_batch_food_logging(callback.message, state, items, status_msg)
1960+
# Remove the message with "Single" mode
1961+
try:
1962+
await callback.message.delete()
1963+
except Exception:
1964+
pass
1965+
else:
1966+
await status_msg.edit_text("❌ ИИ не смог разделить этот ввод на отдельные продукты.")
1967+
except Exception as e:
1968+
logger.error(f"Split Error: {e}", exc_info=True)
1969+
await status_msg.edit_text(f"❌ Ошибка разделения: {e}")
1970+
1971+
1972+
@router.callback_query(F.data == "u_combine_to_single")
1973+
async def handle_combine_to_single(callback: types.CallbackQuery, state: FSMContext) -> None:
1974+
"""User wants to combine multiple items into one single dish."""
1975+
data = await state.get_data()
1976+
# Recover original description from universal_data if possible
1977+
description = (data.get("universal_data", {}).get("content") or
1978+
callback.message.text.split("\n")[0].replace("📋 Введено", "").strip())
1979+
1980+
if not description:
1981+
await callback.answer("❌ Ошибка: не удалось найти основной текст.", show_alert=True)
1982+
return
1983+
1984+
status_msg = await callback.message.answer("🥗 <b>Объединяю в одно блюдо...</b>", parse_mode="HTML")
1985+
await callback.answer()
1986+
1987+
try:
1988+
# Call AI Brain with force_single=True
1989+
brain_result = await AIBrainService.analyze_text(description, force_single=True)
1990+
1991+
if brain_result and isinstance(brain_result, dict) and not brain_result.get("multi"):
1992+
product = brain_result.get("product")
1993+
weight = brain_result.get("weight")
1994+
1995+
# Route to single item flow
1996+
from handlers.universal_input import process_text_food_logging
1997+
await process_text_food_logging(callback.message, state, product, weight_override=weight, status_msg=status_msg)
1998+
1999+
# Remove the message with "Batch" mode
2000+
try:
2001+
await callback.message.delete()
2002+
except Exception:
2003+
pass
2004+
else:
2005+
await status_msg.edit_text("❌ ИИ не смог объединить ввод в одно блюдо.")
2006+
except Exception as e:
2007+
logger.error(f"Combine Error: {e}", exc_info=True)
2008+
await status_msg.edit_text(f"❌ Ошибка объединения: {e}")

0 commit comments

Comments
 (0)