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
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).\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"удали задачу 5" -> {"ACTION":"delete","TASK_TEXT":"","DATE":"","DATE_LABEL":"","TASK_ID":5,"KEYWORD":""}\n"перенеси задачу 1 на завтра" -> {"ACTION":"move_one","TASK_TEXT":"","DATE":"<tomorrow>","DATE_LABEL":"завтра","TASK_ID":1,"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 = ('алиса', 'алис')
@dataclass
class ParseResult:
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 задачи и ключевое слово для поиска.

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:
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).

CommandParser()
231    def __init__(self):
232        """Инициализирует CommandParser."""
233        self._prompt = None

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

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

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