2929from services .herbalife_expert import herbalife_expert
3030from services .normalization import NormalizationService
3131from services .voice_stt import SpeechToText
32+ from utils .parsing import safe_float
3233
3334router = Router ()
3435logger = 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 )
17541857async 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