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
PROMPT_NAME = 'alice-todo-command-parser'
PROMPT_CACHE_FILE = './data/prompt.txt'
PROMPT_TEMPLATE = 'Today is {{today}}.\nUtterance: {{utterance}}\nParse the user\'s utterance for a personal task manager skill. Return ONLY a JSON object with these fields:\n- ACTION: one of "add_task", "list_tasks", "what", "complete", "delete", "move_one", "move_all", "help", "unknown"\n- TASK_TEXT: string, the task to create (only for add_task), WITHOUT any date. Empty string otherwise.\n- DATE: string, the absolute date as YYYY-MM-DD, computed from today (\'{{today}}\'). Resolve relative and word dates the user said: \'завтра\' -> tomorrow, \'восьмое апреля\' -> that day, \'через 5 дней\' -> that offset, \'в пятницу\' -> next Friday. If the resolved date is in the past, use the same date next year. Use empty string "" if no date is given.\n- DATE_LABEL: string, a human-readable date phrase in Russian as the user said it (e.g. \'завтра\', \'восьмое апреля\', \'через 5 дней\', \'в пятницу\'), used in user-facing responses. Use empty string "" if no date is given.\n- TASK_ID: integer or null, the numeric task ID the user refers to (for complete, delete, move_one). The user often says the number in words: convert it to digits exactly (\'один\' -> 1, \'пятьсот двадцать девять\' -> 529, \'шестьсот двадцать девять\' -> 629, \'тысяча\' -> 1000). Copy digits verbatim when the user already spoke them.\n- KEYWORD: string, the search noun for "what" queries (e.g. "купить" from "что купить"). Empty otherwise.\n\nAction rules:\n- add_task: user wants to add a task. Put the task text (minus any date) in TASK_TEXT, the resolved date in DATE and its label in DATE_LABEL (both "" if none).\n- list_tasks: user asks what tasks exist ("какие задачи", "покажи список", "дела"). Put DATE as the requested day ("" if all), and DATE_LABEL as the human phrase.\n- what: user asks "что X?" to find tasks starting with a keyword. Put keyword in KEYWORD, date in DATE and DATE_LABEL.\n- complete: user marks a task done ("пометь задачу N", "сделал N", "задача N выполнена"). Put N in TASK_ID.\n- delete: user removes a task ("удали задачу N"). Put N in TASK_ID.\n- move_one: user moves ONE task ("перенеси задачу N"). Put N in TASK_ID, target date in DATE, its label in DATE_LABEL.\n- move_all: user moves ALL tasks ("перенеси задачи"). Put target date in DATE and DATE_LABEL.\n- help: user asks for help.\n- unknown: anything unrelated to tasks.\n\nExamples:\n"добавь задачу на завтра сходить к врачу" -> {"ACTION":"add_task","TASK_TEXT":"сходить к врачу","DATE":"<tomorrow>","DATE_LABEL":"завтра","TASK_ID":null,"KEYWORD":""}\n"добавь задачу на 7 ноября встреча" -> {"ACTION":"add_task","TASK_TEXT":"встреча","DATE":"<7 ноября этого года>","DATE_LABEL":"7 ноября","TASK_ID":null,"KEYWORD":""}\n"добавь задачу на восьмое апреля сделать уборку" -> {"ACTION":"add_task","TASK_TEXT":"сделать уборку","DATE":"<8 апреля>","DATE_LABEL":"восьмое апреля","TASK_ID":null,"KEYWORD":""}\n"добавь задачу купить молоко" -> {"ACTION":"add_task","TASK_TEXT":"купить молоко","DATE":"","DATE_LABEL":"","TASK_ID":null,"KEYWORD":""}\n"какие задачи на завтра" -> {"ACTION":"list_tasks","TASK_TEXT":"","DATE":"<tomorrow>","DATE_LABEL":"завтра","TASK_ID":null,"KEYWORD":""}\n"дела на сегодня" -> {"ACTION":"list_tasks","TASK_TEXT":"","DATE":"<today>","DATE_LABEL":"сегодня","TASK_ID":null,"KEYWORD":""}\n"что купить" -> {"ACTION":"what","TASK_TEXT":"","DATE":"","DATE_LABEL":"","TASK_ID":null,"KEYWORD":"купить"}\n"пометь задачу 3 выполненной" -> {"ACTION":"complete","TASK_TEXT":"","DATE":"","DATE_LABEL":"","TASK_ID":3,"KEYWORD":""}\n"задача шестьсот двадцать девять выполнена" -> {"ACTION":"complete","TASK_TEXT":"","DATE":"","DATE_LABEL":"","TASK_ID":629,"KEYWORD":""}\n"сделал двести пятьдесят четыре" -> {"ACTION":"complete","TASK_TEXT":"","DATE":"","DATE_LABEL":"","TASK_ID":254,"KEYWORD":""}\n"удали задачу 5" -> {"ACTION":"delete","TASK_TEXT":"","DATE":"","DATE_LABEL":"","TASK_ID":5,"KEYWORD":""}\n"удали задачу семьсот тридцать один" -> {"ACTION":"delete","TASK_TEXT":"","DATE":"","DATE_LABEL":"","TASK_ID":731,"KEYWORD":""}\n"перенеси задачу 1 на завтра" -> {"ACTION":"move_one","TASK_TEXT":"","DATE":"<tomorrow>","DATE_LABEL":"завтра","TASK_ID":1,"KEYWORD":""}\n"перенеси задачу четыреста двенадцать на пятницу" -> {"ACTION":"move_one","TASK_TEXT":"","DATE":"<next friday>","DATE_LABEL":"в пятницу","TASK_ID":412,"KEYWORD":""}\n"перенеси задачу на завтра" -> {"ACTION":"move_one","TASK_TEXT":"","DATE":"<tomorrow>","DATE_LABEL":"завтра","TASK_ID":null,"KEYWORD":""}\n"перенеси задачи на послезавтра" -> {"ACTION":"move_all","TASK_TEXT":"","DATE":"<day after tomorrow>","DATE_LABEL":"послезавтра","TASK_ID":null,"KEYWORD":""}\n"помощь" -> {"ACTION":"help","TASK_TEXT":"","DATE":"","DATE_LABEL":"","TASK_ID":null,"KEYWORD":""}\n\nReturn only the JSON object, no additional text.\n'
LLM_MODEL = 'e.anisimov/eagent'
JSON_SCHEMA = {'type': 'json_schema', 'json_schema': {'name': 'command_parse_result', 'strict': True, 'schema': {'type': 'object', 'additionalProperties': False, 'properties': {'ACTION': {'type': 'string'}, 'TASK_TEXT': {'type': 'string'}, 'DATE': {'type': 'string'}, 'DATE_LABEL': {'type': 'string'}, 'TASK_ID': {'anyOf': [{'type': 'integer'}, {'type': 'null'}]}, 'KEYWORD': {'type': 'string'}}, 'required': ['ACTION', 'TASK_TEXT', 'DATE', 'DATE_LABEL', 'TASK_ID', 'KEYWORD']}}}
ALICE_WORDS = ('алиса', 'алис')
def extract_task_id(command: str, nlu: Optional[models.Nlu] = None) -> Optional[int]:
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, если числа в запросе нет

@dataclass
class ParseResult:
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 задачи и ключевое слово для поиска.

ParseResult( action: str, task_text: str = '', date_keyword: Optional[str] = None, task_id: Optional[int] = None, keyword: str = '', date_label: Optional[str] = None)
action: str
task_text: str = ''
date_keyword: Optional[str] = None
task_id: Optional[int] = None
keyword: str = ''
date_label: Optional[str] = None
class CommandParser:
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).

CommandParser()
295    def __init__(self):
296        """Инициализирует CommandParser."""
297        self._prompt = None

Инициализирует CommandParser.

def cache_prompt(self):
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 продолжать работать с последней версии.

@observe(name='Parse command')
def parse( self, command: str, original_utterance: str, task_id_hint: Optional[int] = None) -> Optional[ParseResult]:
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, если команда не распознана