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