command_parser
Парсинг команд Яндекс Алисы через локальную LLM.
Использует локальную модель (e-agent, OpenAI-совместимый API) для детерминированного разбора естественно-языковой команды в JSON с полями ACTION/TASK_TEXT/DATE/DATE_LABEL/TASK_ID/KEYWORD. Шаблон промпта хранится и управляется через Langfuse, с фолбэком на файловый кэш и встроенный bootstrap-шаблон. Трейсинг LLM-вызова обеспечивается @observe на parse().
1""" 2Парсинг команд Яндекс Алисы через локальную LLM. 3 4Использует локальную модель (e-agent, OpenAI-совместимый API) для 5детерминированного разбора естественно-языковой команды в JSON с полями 6ACTION/TASK_TEXT/DATE/DATE_LABEL/TASK_ID/KEYWORD. Шаблон промпта хранится 7и управляется через Langfuse, с фолбэком на файловый кэш и встроенный 8bootstrap-шаблон. Трейсинг LLM-вызова обеспечивается @observe на parse(). 9""" 10 11import json 12import os 13 14from dataclasses import dataclass 15from typing import Optional 16 17from langfuse import get_client, observe 18from loguru import logger 19 20from taskman import DATA_DIR 21 22 23PROMPT_NAME = "alice-todo-command-parser" 24PROMPT_CACHE_FILE = os.path.join(DATA_DIR, "prompt.txt") 25 26PROMPT_TEMPLATE = ( 27 "Today is {{today}}.\n" 28 "Utterance: {{utterance}}\n" 29 "Parse the user's utterance for a personal task manager skill. " 30 "Return ONLY a JSON object with these fields:\n" 31 '- ACTION: one of "add_task", "list_tasks", "what", "complete", ' 32 '"delete", "move_one", "move_all", "help", "unknown"\n' 33 "- TASK_TEXT: string, the task to create (only for add_task), WITHOUT any " 34 "date. Empty string otherwise.\n" 35 "- DATE: string, the absolute date as YYYY-MM-DD, computed from today " 36 "('{{today}}'). Resolve relative and word dates the user said: 'завтра' -> " 37 "tomorrow, 'восьмое апреля' -> that day, 'через 5 дней' -> that offset, " 38 "'в пятницу' -> next Friday. If the resolved date is in the past, use the " 39 'same date next year. Use empty string "" if no date is given.\n' 40 "- DATE_LABEL: string, a human-readable date phrase in Russian as the user " 41 "said it (e.g. 'завтра', 'восьмое апреля', 'через 5 дней', 'в пятницу'), " 42 'used in user-facing responses. Use empty string "" if no date is given.\n' 43 "- TASK_ID: integer or null, the numeric task ID the user refers to " 44 "(for complete, delete, move_one).\n" 45 '- KEYWORD: string, the search noun for "what" queries ' 46 '(e.g. "купить" from "что купить"). Empty otherwise.\n' 47 "\n" 48 "Action rules:\n" 49 "- add_task: user wants to add a task. Put the task text (minus any date) " 50 "in TASK_TEXT, the resolved date in DATE and its label in DATE_LABEL " 51 '(both "" if none).\n' 52 '- list_tasks: user asks what tasks exist ("какие задачи", ' 53 '"покажи список", "дела"). Put DATE as the requested day ("" if all), ' 54 "and DATE_LABEL as the human phrase.\n" 55 '- what: user asks "что X?" to find tasks starting with a keyword. ' 56 "Put keyword in KEYWORD, date in DATE and DATE_LABEL.\n" 57 '- complete: user marks a task done ("пометь задачу N", "сделал N", ' 58 '"задача N выполнена"). Put N in TASK_ID.\n' 59 '- delete: user removes a task ("удали задачу N"). Put N in TASK_ID.\n' 60 '- move_one: user moves ONE task ("перенеси задачу N"). Put N in ' 61 "TASK_ID, target date in DATE, its label in DATE_LABEL.\n" 62 '- move_all: user moves ALL tasks ("перенеси задачи"). Put target date ' 63 "in DATE and DATE_LABEL.\n" 64 "- help: user asks for help.\n" 65 "- unknown: anything unrelated to tasks.\n" 66 "\n" 67 "Examples:\n" 68 '"добавь задачу на завтра сходить к врачу" -> ' 69 '{"ACTION":"add_task","TASK_TEXT":"сходить к врачу",' 70 '"DATE":"<tomorrow>","DATE_LABEL":"завтра","TASK_ID":null,"KEYWORD":""}\n' 71 '"добавь задачу на 7 ноября встреча" -> ' 72 '{"ACTION":"add_task","TASK_TEXT":"встреча",' 73 '"DATE":"<7 ноября этого года>","DATE_LABEL":"7 ноября",' 74 '"TASK_ID":null,"KEYWORD":""}\n' 75 '"добавь задачу на восьмое апреля сделать уборку" -> ' 76 '{"ACTION":"add_task","TASK_TEXT":"сделать уборку",' 77 '"DATE":"<8 апреля>","DATE_LABEL":"восьмое апреля",' 78 '"TASK_ID":null,"KEYWORD":""}\n' 79 '"добавь задачу купить молоко" -> ' 80 '{"ACTION":"add_task","TASK_TEXT":"купить молоко",' 81 '"DATE":"","DATE_LABEL":"","TASK_ID":null,"KEYWORD":""}\n' 82 '"какие задачи на завтра" -> ' 83 '{"ACTION":"list_tasks","TASK_TEXT":"","DATE":"<tomorrow>",' 84 '"DATE_LABEL":"завтра","TASK_ID":null,"KEYWORD":""}\n' 85 '"дела на сегодня" -> ' 86 '{"ACTION":"list_tasks","TASK_TEXT":"","DATE":"<today>",' 87 '"DATE_LABEL":"сегодня","TASK_ID":null,"KEYWORD":""}\n' 88 '"что купить" -> ' 89 '{"ACTION":"what","TASK_TEXT":"","DATE":"","DATE_LABEL":"",' 90 '"TASK_ID":null,"KEYWORD":"купить"}\n' 91 '"пометь задачу 3 выполненной" -> ' 92 '{"ACTION":"complete","TASK_TEXT":"","DATE":"","DATE_LABEL":"",' 93 '"TASK_ID":3,"KEYWORD":""}\n' 94 '"удали задачу 5" -> ' 95 '{"ACTION":"delete","TASK_TEXT":"","DATE":"","DATE_LABEL":"",' 96 '"TASK_ID":5,"KEYWORD":""}\n' 97 '"перенеси задачу 1 на завтра" -> ' 98 '{"ACTION":"move_one","TASK_TEXT":"","DATE":"<tomorrow>",' 99 '"DATE_LABEL":"завтра","TASK_ID":1,"KEYWORD":""}\n' 100 '"перенеси задачу на завтра" -> ' 101 '{"ACTION":"move_one","TASK_TEXT":"","DATE":"<tomorrow>",' 102 '"DATE_LABEL":"завтра","TASK_ID":null,"KEYWORD":""}\n' 103 '"перенеси задачи на послезавтра" -> ' 104 '{"ACTION":"move_all","TASK_TEXT":"","DATE":"<day after tomorrow>",' 105 '"DATE_LABEL":"послезавтра","TASK_ID":null,"KEYWORD":""}\n' 106 '"помощь" -> {"ACTION":"help","TASK_TEXT":"","DATE":"",' 107 '"DATE_LABEL":"","TASK_ID":null,"KEYWORD":""}\n' 108 "\n" 109 "Return only the JSON object, no additional text.\n" 110) 111 112 113def _render_template(template: str, utterance: str, today: str) -> str: 114 """Подставляет utterance и сегодняшнюю дату в шаблон промпта.""" 115 text = template.replace("{{utterance}}", utterance) 116 return text.replace("{{today}}", today) 117 118 119def _cache_prompt(text: str) -> None: 120 """Сохраняет промпт в файл кэша (фолбэк при недоступности Langfuse).""" 121 try: 122 os.makedirs(DATA_DIR, exist_ok=True) 123 with open(PROMPT_CACHE_FILE, "w", encoding="utf-8") as f: 124 f.write(text) 125 except OSError as e: 126 logger.warning(f"Langfuse: не удалось записать кэш промпта: {e}") 127 128 129def _read_cached_prompt() -> Optional[str]: 130 """Возвращает промпт из файла кэша или None.""" 131 try: 132 with open(PROMPT_CACHE_FILE, encoding="utf-8") as f: 133 return f.read() 134 except OSError: 135 return None 136 137 138LLM_MODEL = "e.anisimov/eagent" 139 140JSON_SCHEMA = { 141 "type": "json_schema", 142 "json_schema": { 143 "name": "command_parse_result", 144 "strict": True, 145 "schema": { 146 "type": "object", 147 "additionalProperties": False, 148 "properties": { 149 "ACTION": {"type": "string"}, 150 "TASK_TEXT": {"type": "string"}, 151 "DATE": {"type": "string"}, 152 "DATE_LABEL": {"type": "string"}, 153 "TASK_ID": {"anyOf": [{"type": "integer"}, {"type": "null"}]}, 154 "KEYWORD": {"type": "string"}, 155 }, 156 "required": [ 157 "ACTION", 158 "TASK_TEXT", 159 "DATE", 160 "DATE_LABEL", 161 "TASK_ID", 162 "KEYWORD", 163 ], 164 }, 165 }, 166} 167 168 169def _make_client(): 170 """Создаёт OpenAI-клиент для обращения к LLM (base_url/api_key из env). 171 172 Ключ берётся ТОЛЬКО из окружения (LLM_API_KEY, задаётся в .env / 173 /srv/AliceTodoSkill.env) — в коде секретов нет. 174 175 Используется чистый openai.OpenAI (без langfuse обёртки), чтобы 176 langfuse не ломал retry-логику при недоступном Langfuse-сервере. 177 Трейсинг LLM-вызова обеспечивается @observe на parse(). 178 """ 179 from openai import OpenAI 180 181 base_url = os.getenv("LLM_BASE_URL", "http://e-agent:18012/v1") 182 api_key = os.getenv("LLM_API_KEY", "") 183 return OpenAI(base_url=base_url, api_key=api_key) 184 185 186ALICE_WORDS = ("алиса", "алис") 187 188 189def _strip_alice(text: str) -> str: 190 """ 191 Удаляет обращение 'Алиса' из фразы. 192 193 Вырезает слово 'Алиса'/'Алис' в любом регистре и позиции, вместе с 194 прилегающей пунктуацией. Примеры: 195 'Алиса, добавь задачу купить молоко' -> 'добавь задачу купить молоко' 196 """ 197 words = text.split() 198 filtered = [ 199 word for word in words if word.lower().strip(",.!?;:«»\"'()") not in ALICE_WORDS 200 ] 201 return " ".join(filtered) 202 203 204@dataclass 205class ParseResult: 206 """ 207 Результат парсинга команды пользователя. 208 209 Содержит идентификатор действия и сопутствующие параметры: 210 текст задачи, дата (ISO YYYY-MM-DD, разрешённая LLM), человеческая 211 подпись даты, ID задачи и ключевое слово для поиска. 212 """ 213 214 action: str 215 task_text: str = "" 216 date_keyword: Optional[str] = None 217 task_id: Optional[int] = None 218 keyword: str = "" 219 date_label: Optional[str] = None 220 221 222class CommandParser: 223 """ 224 Парсер голосовых команд для навыка управления задачами. 225 226 Использует локальную LLM для анализа входящего текста и извлечения 227 структурированных данных (ACTION/TASK_TEXT/DATE/TASK_ID/KEYWORD). 228 """ 229 230 def __init__(self): 231 """Инициализирует CommandParser.""" 232 self._prompt = None 233 234 def cache_prompt(self): 235 """ 236 Кэширует промпт из Langfuse в data/prompt.txt. 237 238 Langfuse — источник правды. Вызывается при старте приложения: 239 получает текущий промпт и сохраняет его текст, чтобы при 240 недоступности Langfuse продолжать работать с последней версии. 241 """ 242 try: 243 client = get_client().get_prompt(PROMPT_NAME) 244 text = getattr(client, "prompt", None) 245 if text: 246 _cache_prompt(text) 247 logger.info( 248 f"Langfuse: промпт '{PROMPT_NAME}' закэширован " 249 f"(version={getattr(client, 'version', '?')})" 250 ) 251 except Exception as e: 252 logger.warning(f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}") 253 254 def _get_prompt(self) -> object: 255 """ 256 Возвращает шаблон промпта из Langfuse или кэша. 257 258 Сначала пытается получить промпт из Langfuse (источник правды); 259 при успехе обновляет кэш в data/prompt.txt. Если Langfuse 260 недоступен — берёт текст из кэша, а если кэша нет — из 261 PROMPT_TEMPLATE (bootstrap). 262 263 Returns: 264 object: TextPromptClient из Langfuse или строковый шаблон 265 (с плейсхолдером {{utterance}}) 266 """ 267 if self._prompt is None: 268 try: 269 client = get_client().get_prompt(PROMPT_NAME) 270 text = getattr(client, "prompt", None) 271 if text: 272 _cache_prompt(text) 273 self._prompt = client 274 return self._prompt 275 except Exception as e: 276 logger.warning( 277 f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}" 278 ) 279 cached = _read_cached_prompt() 280 self._prompt = cached if cached is not None else PROMPT_TEMPLATE 281 return self._prompt 282 283 def _build_prompt(self, utterance: str) -> tuple[str, Optional[object]]: 284 """ 285 Собирает финальный текст промпта для конкретной utterance. 286 287 Args: 288 utterance: Высказывание пользователя 289 290 Returns: 291 tuple[str, Optional[object]]: Текст промпта и объект промпта 292 Langfuse (для привязки версии к генерации) или None 293 """ 294 from datetime import datetime as _dt 295 296 today = _dt.now().strftime("%Y-%m-%d") 297 prompt_source = self._get_prompt() 298 if isinstance(prompt_source, str): 299 return _render_template(prompt_source, utterance, today), None 300 compile_method = getattr(prompt_source, "compile", None) 301 if compile_method is not None: 302 try: 303 rendered = compile_method(utterance=utterance) 304 return rendered.replace("{{today}}", today), prompt_source 305 except Exception: 306 pass 307 return _render_template(PROMPT_TEMPLATE, utterance, today), None 308 309 def _call_llm(self, utterance: str) -> Optional[dict]: 310 """ 311 Вызов LLM для парсинга естественного языка. 312 313 Использует GPT-OSS через OpenAI-compatible API. 314 Трейсинг обеспечивается @observe на parse(). 315 316 Args: 317 utterance: Оригинальное высказывание пользователя 318 319 Returns: 320 Optional[dict]: Словарь с полями ACTION, TASK_TEXT, DATE, 321 TASK_ID, KEYWORD или None при ошибке 322 """ 323 prompt, _ = self._build_prompt(utterance) 324 325 client = _make_client() 326 327 for attempt in range(3): 328 try: 329 response = client.chat.completions.create( 330 model=LLM_MODEL, 331 messages=[{"role": "user", "content": prompt}], 332 temperature=0.1, 333 max_tokens=512, 334 response_format=JSON_SCHEMA, 335 ) 336 337 text = (response.choices[0].message.content or "").strip() 338 if text.startswith("```json"): 339 text = text[7:] 340 if text.startswith("```"): 341 text = text[3:] 342 if text.endswith("```"): 343 text = text[:-3] 344 text = text.strip() 345 346 return json.loads(text) 347 except Exception as e: 348 logger.error(f"Ошибка вызова LLM (попытка {attempt + 1}/3): {e}") 349 350 return None 351 352 @observe(name="Parse command") 353 def parse(self, command: str, original_utterance: str) -> Optional[ParseResult]: 354 """ 355 Анализирует команду пользователя и возвращает структурированный результат. 356 357 Определяет тип действия (ping, add_task, list_tasks, complete, 358 delete, move_one, move_all, help, what) на основе LLM-ответа. 359 360 Args: 361 command: Нормализованная команда (в нижнем регистре) 362 original_utterance: Оригинальное высказывание пользователя 363 364 Returns: 365 Optional[ParseResult]: Результат парсинга или None, если команда не распознана 366 """ 367 if original_utterance == "ping": 368 return ParseResult(action="ping") 369 370 cleaned = _strip_alice(original_utterance) 371 if not cleaned.strip(): 372 return None 373 374 cmd = cleaned.lower().strip() 375 if cmd in ("добавь задачу", "добавить задачу", "создай задачу"): 376 return ParseResult(action="no_task_text") 377 378 llm_result = self._call_llm(cleaned) 379 if llm_result is None: 380 return None 381 382 action = (llm_result.get("ACTION") or "").strip().lower() 383 date = (llm_result.get("DATE") or "").strip() 384 label = (llm_result.get("DATE_LABEL") or "").strip() 385 386 from datetime import datetime as _dt, timedelta as _td 387 388 tomorrow = (_dt.now() + _td(days=1)).strftime("%Y-%m-%d") 389 390 if action == "add_task": 391 task_text = (llm_result.get("TASK_TEXT") or "").strip().rstrip(".,!?") 392 if not task_text: 393 return ParseResult(action="no_task_text") 394 return ParseResult( 395 action="add_task", 396 task_text=task_text, 397 date_keyword=date or None, 398 date_label=label or date, 399 ) 400 elif action == "list_tasks": 401 return ParseResult( 402 action="list_tasks", 403 date_keyword=date or None, 404 date_label=label or date or None, 405 ) 406 elif action == "what": 407 keyword = (llm_result.get("KEYWORD") or "").strip() 408 return ParseResult( 409 action="what", 410 keyword=keyword, 411 date_keyword=date or None, 412 date_label=label or date or None, 413 ) 414 elif action == "complete": 415 task_id = llm_result.get("TASK_ID") 416 if task_id is None: 417 return ParseResult(action="no_task_id") 418 return ParseResult(action="complete", task_id=int(task_id)) 419 elif action == "delete": 420 task_id = llm_result.get("TASK_ID") 421 if task_id is None: 422 return ParseResult(action="no_task_id") 423 return ParseResult(action="delete", task_id=int(task_id)) 424 elif action == "move_one": 425 task_id = llm_result.get("TASK_ID") 426 if task_id is None: 427 return ParseResult(action="no_task_id") 428 return ParseResult( 429 action="move_one", 430 task_id=int(task_id), 431 date_keyword=date or tomorrow, 432 date_label=label or date or "завтра", 433 ) 434 elif action == "move_all": 435 return ParseResult( 436 action="move_all", 437 date_keyword=date or tomorrow, 438 date_label=label or date or "завтра", 439 ) 440 elif action == "help": 441 return ParseResult(action="help") 442 443 return None
205@dataclass 206class ParseResult: 207 """ 208 Результат парсинга команды пользователя. 209 210 Содержит идентификатор действия и сопутствующие параметры: 211 текст задачи, дата (ISO YYYY-MM-DD, разрешённая LLM), человеческая 212 подпись даты, ID задачи и ключевое слово для поиска. 213 """ 214 215 action: str 216 task_text: str = "" 217 date_keyword: Optional[str] = None 218 task_id: Optional[int] = None 219 keyword: str = "" 220 date_label: Optional[str] = None
Результат парсинга команды пользователя.
Содержит идентификатор действия и сопутствующие параметры: текст задачи, дата (ISO YYYY-MM-DD, разрешённая LLM), человеческая подпись даты, ID задачи и ключевое слово для поиска.
223class CommandParser: 224 """ 225 Парсер голосовых команд для навыка управления задачами. 226 227 Использует локальную LLM для анализа входящего текста и извлечения 228 структурированных данных (ACTION/TASK_TEXT/DATE/TASK_ID/KEYWORD). 229 """ 230 231 def __init__(self): 232 """Инициализирует CommandParser.""" 233 self._prompt = None 234 235 def cache_prompt(self): 236 """ 237 Кэширует промпт из Langfuse в data/prompt.txt. 238 239 Langfuse — источник правды. Вызывается при старте приложения: 240 получает текущий промпт и сохраняет его текст, чтобы при 241 недоступности Langfuse продолжать работать с последней версии. 242 """ 243 try: 244 client = get_client().get_prompt(PROMPT_NAME) 245 text = getattr(client, "prompt", None) 246 if text: 247 _cache_prompt(text) 248 logger.info( 249 f"Langfuse: промпт '{PROMPT_NAME}' закэширован " 250 f"(version={getattr(client, 'version', '?')})" 251 ) 252 except Exception as e: 253 logger.warning(f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}") 254 255 def _get_prompt(self) -> object: 256 """ 257 Возвращает шаблон промпта из Langfuse или кэша. 258 259 Сначала пытается получить промпт из Langfuse (источник правды); 260 при успехе обновляет кэш в data/prompt.txt. Если Langfuse 261 недоступен — берёт текст из кэша, а если кэша нет — из 262 PROMPT_TEMPLATE (bootstrap). 263 264 Returns: 265 object: TextPromptClient из Langfuse или строковый шаблон 266 (с плейсхолдером {{utterance}}) 267 """ 268 if self._prompt is None: 269 try: 270 client = get_client().get_prompt(PROMPT_NAME) 271 text = getattr(client, "prompt", None) 272 if text: 273 _cache_prompt(text) 274 self._prompt = client 275 return self._prompt 276 except Exception as e: 277 logger.warning( 278 f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}" 279 ) 280 cached = _read_cached_prompt() 281 self._prompt = cached if cached is not None else PROMPT_TEMPLATE 282 return self._prompt 283 284 def _build_prompt(self, utterance: str) -> tuple[str, Optional[object]]: 285 """ 286 Собирает финальный текст промпта для конкретной utterance. 287 288 Args: 289 utterance: Высказывание пользователя 290 291 Returns: 292 tuple[str, Optional[object]]: Текст промпта и объект промпта 293 Langfuse (для привязки версии к генерации) или None 294 """ 295 from datetime import datetime as _dt 296 297 today = _dt.now().strftime("%Y-%m-%d") 298 prompt_source = self._get_prompt() 299 if isinstance(prompt_source, str): 300 return _render_template(prompt_source, utterance, today), None 301 compile_method = getattr(prompt_source, "compile", None) 302 if compile_method is not None: 303 try: 304 rendered = compile_method(utterance=utterance) 305 return rendered.replace("{{today}}", today), prompt_source 306 except Exception: 307 pass 308 return _render_template(PROMPT_TEMPLATE, utterance, today), None 309 310 def _call_llm(self, utterance: str) -> Optional[dict]: 311 """ 312 Вызов LLM для парсинга естественного языка. 313 314 Использует GPT-OSS через OpenAI-compatible API. 315 Трейсинг обеспечивается @observe на parse(). 316 317 Args: 318 utterance: Оригинальное высказывание пользователя 319 320 Returns: 321 Optional[dict]: Словарь с полями ACTION, TASK_TEXT, DATE, 322 TASK_ID, KEYWORD или None при ошибке 323 """ 324 prompt, _ = self._build_prompt(utterance) 325 326 client = _make_client() 327 328 for attempt in range(3): 329 try: 330 response = client.chat.completions.create( 331 model=LLM_MODEL, 332 messages=[{"role": "user", "content": prompt}], 333 temperature=0.1, 334 max_tokens=512, 335 response_format=JSON_SCHEMA, 336 ) 337 338 text = (response.choices[0].message.content or "").strip() 339 if text.startswith("```json"): 340 text = text[7:] 341 if text.startswith("```"): 342 text = text[3:] 343 if text.endswith("```"): 344 text = text[:-3] 345 text = text.strip() 346 347 return json.loads(text) 348 except Exception as e: 349 logger.error(f"Ошибка вызова LLM (попытка {attempt + 1}/3): {e}") 350 351 return None 352 353 @observe(name="Parse command") 354 def parse(self, command: str, original_utterance: str) -> Optional[ParseResult]: 355 """ 356 Анализирует команду пользователя и возвращает структурированный результат. 357 358 Определяет тип действия (ping, add_task, list_tasks, complete, 359 delete, move_one, move_all, help, what) на основе LLM-ответа. 360 361 Args: 362 command: Нормализованная команда (в нижнем регистре) 363 original_utterance: Оригинальное высказывание пользователя 364 365 Returns: 366 Optional[ParseResult]: Результат парсинга или None, если команда не распознана 367 """ 368 if original_utterance == "ping": 369 return ParseResult(action="ping") 370 371 cleaned = _strip_alice(original_utterance) 372 if not cleaned.strip(): 373 return None 374 375 cmd = cleaned.lower().strip() 376 if cmd in ("добавь задачу", "добавить задачу", "создай задачу"): 377 return ParseResult(action="no_task_text") 378 379 llm_result = self._call_llm(cleaned) 380 if llm_result is None: 381 return None 382 383 action = (llm_result.get("ACTION") or "").strip().lower() 384 date = (llm_result.get("DATE") or "").strip() 385 label = (llm_result.get("DATE_LABEL") or "").strip() 386 387 from datetime import datetime as _dt, timedelta as _td 388 389 tomorrow = (_dt.now() + _td(days=1)).strftime("%Y-%m-%d") 390 391 if action == "add_task": 392 task_text = (llm_result.get("TASK_TEXT") or "").strip().rstrip(".,!?") 393 if not task_text: 394 return ParseResult(action="no_task_text") 395 return ParseResult( 396 action="add_task", 397 task_text=task_text, 398 date_keyword=date or None, 399 date_label=label or date, 400 ) 401 elif action == "list_tasks": 402 return ParseResult( 403 action="list_tasks", 404 date_keyword=date or None, 405 date_label=label or date or None, 406 ) 407 elif action == "what": 408 keyword = (llm_result.get("KEYWORD") or "").strip() 409 return ParseResult( 410 action="what", 411 keyword=keyword, 412 date_keyword=date or None, 413 date_label=label or date or None, 414 ) 415 elif action == "complete": 416 task_id = llm_result.get("TASK_ID") 417 if task_id is None: 418 return ParseResult(action="no_task_id") 419 return ParseResult(action="complete", task_id=int(task_id)) 420 elif action == "delete": 421 task_id = llm_result.get("TASK_ID") 422 if task_id is None: 423 return ParseResult(action="no_task_id") 424 return ParseResult(action="delete", task_id=int(task_id)) 425 elif action == "move_one": 426 task_id = llm_result.get("TASK_ID") 427 if task_id is None: 428 return ParseResult(action="no_task_id") 429 return ParseResult( 430 action="move_one", 431 task_id=int(task_id), 432 date_keyword=date or tomorrow, 433 date_label=label or date or "завтра", 434 ) 435 elif action == "move_all": 436 return ParseResult( 437 action="move_all", 438 date_keyword=date or tomorrow, 439 date_label=label or date or "завтра", 440 ) 441 elif action == "help": 442 return ParseResult(action="help") 443 444 return None
Парсер голосовых команд для навыка управления задачами.
Использует локальную LLM для анализа входящего текста и извлечения структурированных данных (ACTION/TASK_TEXT/DATE/TASK_ID/KEYWORD).
235 def cache_prompt(self): 236 """ 237 Кэширует промпт из Langfuse в data/prompt.txt. 238 239 Langfuse — источник правды. Вызывается при старте приложения: 240 получает текущий промпт и сохраняет его текст, чтобы при 241 недоступности Langfuse продолжать работать с последней версии. 242 """ 243 try: 244 client = get_client().get_prompt(PROMPT_NAME) 245 text = getattr(client, "prompt", None) 246 if text: 247 _cache_prompt(text) 248 logger.info( 249 f"Langfuse: промпт '{PROMPT_NAME}' закэширован " 250 f"(version={getattr(client, 'version', '?')})" 251 ) 252 except Exception as e: 253 logger.warning(f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}")
Кэширует промпт из Langfuse в data/prompt.txt.
Langfuse — источник правды. Вызывается при старте приложения: получает текущий промпт и сохраняет его текст, чтобы при недоступности Langfuse продолжать работать с последней версии.
353 @observe(name="Parse command") 354 def parse(self, command: str, original_utterance: str) -> Optional[ParseResult]: 355 """ 356 Анализирует команду пользователя и возвращает структурированный результат. 357 358 Определяет тип действия (ping, add_task, list_tasks, complete, 359 delete, move_one, move_all, help, what) на основе LLM-ответа. 360 361 Args: 362 command: Нормализованная команда (в нижнем регистре) 363 original_utterance: Оригинальное высказывание пользователя 364 365 Returns: 366 Optional[ParseResult]: Результат парсинга или None, если команда не распознана 367 """ 368 if original_utterance == "ping": 369 return ParseResult(action="ping") 370 371 cleaned = _strip_alice(original_utterance) 372 if not cleaned.strip(): 373 return None 374 375 cmd = cleaned.lower().strip() 376 if cmd in ("добавь задачу", "добавить задачу", "создай задачу"): 377 return ParseResult(action="no_task_text") 378 379 llm_result = self._call_llm(cleaned) 380 if llm_result is None: 381 return None 382 383 action = (llm_result.get("ACTION") or "").strip().lower() 384 date = (llm_result.get("DATE") or "").strip() 385 label = (llm_result.get("DATE_LABEL") or "").strip() 386 387 from datetime import datetime as _dt, timedelta as _td 388 389 tomorrow = (_dt.now() + _td(days=1)).strftime("%Y-%m-%d") 390 391 if action == "add_task": 392 task_text = (llm_result.get("TASK_TEXT") or "").strip().rstrip(".,!?") 393 if not task_text: 394 return ParseResult(action="no_task_text") 395 return ParseResult( 396 action="add_task", 397 task_text=task_text, 398 date_keyword=date or None, 399 date_label=label or date, 400 ) 401 elif action == "list_tasks": 402 return ParseResult( 403 action="list_tasks", 404 date_keyword=date or None, 405 date_label=label or date or None, 406 ) 407 elif action == "what": 408 keyword = (llm_result.get("KEYWORD") or "").strip() 409 return ParseResult( 410 action="what", 411 keyword=keyword, 412 date_keyword=date or None, 413 date_label=label or date or None, 414 ) 415 elif action == "complete": 416 task_id = llm_result.get("TASK_ID") 417 if task_id is None: 418 return ParseResult(action="no_task_id") 419 return ParseResult(action="complete", task_id=int(task_id)) 420 elif action == "delete": 421 task_id = llm_result.get("TASK_ID") 422 if task_id is None: 423 return ParseResult(action="no_task_id") 424 return ParseResult(action="delete", task_id=int(task_id)) 425 elif action == "move_one": 426 task_id = llm_result.get("TASK_ID") 427 if task_id is None: 428 return ParseResult(action="no_task_id") 429 return ParseResult( 430 action="move_one", 431 task_id=int(task_id), 432 date_keyword=date or tomorrow, 433 date_label=label or date or "завтра", 434 ) 435 elif action == "move_all": 436 return ParseResult( 437 action="move_all", 438 date_keyword=date or tomorrow, 439 date_label=label or date or "завтра", 440 ) 441 elif action == "help": 442 return ParseResult(action="help") 443 444 return None
Анализирует команду пользователя и возвращает структурированный результат.
Определяет тип действия (ping, add_task, list_tasks, complete, delete, move_one, move_all, help, what) на основе LLM-ответа.
Args: command: Нормализованная команда (в нижнем регистре) original_utterance: Оригинальное высказывание пользователя
Returns: Optional[ParseResult]: Результат парсинга или None, если команда не распознана