command_parser

Парсинг текстовых команд Яндекс Алисы через LLM.

Использует GPT-OSS через OpenAI-compatible API: парсит естественный язык в структурированные данные (COMMAND/THING/PLACE). Обращения к LLM логируются в Langfuse (обёртка langfuse.openai), шаблон промпта хранится и управляется через Langfuse.

  1"""
  2Парсинг текстовых команд Яндекс Алисы через LLM.
  3
  4Использует GPT-OSS через OpenAI-compatible API: парсит естественный язык
  5в структурированные данные (COMMAND/THING/PLACE). Обращения к LLM
  6логируются в Langfuse (обёртка langfuse.openai), шаблон промпта
  7хранится и управляется через Langfuse.
  8"""
  9
 10import json
 11import os
 12from dataclasses import dataclass
 13from typing import Optional
 14
 15from langfuse import get_client
 16from langfuse.openai import OpenAI
 17from loguru import logger
 18
 19from thingman import DATA_DIR
 20
 21
 22PROMPT_NAME = "lostandfound-command-parser"
 23PROMPT_CACHE_FILE = os.path.join(DATA_DIR, "prompt.txt")
 24PROMPT_TEMPLATE = (
 25    "Utterance: {{utterance}}\n"
 26    "Parse it into JSON with fields COMMAND ('INSERT' or 'QUERY'), THING, and PLACE.\n"
 27    "Determine if user states a location (INSERT) or asks a question (QUERY).\n"
 28    "Return THING as the complete noun phrase the user said, keeping all modifiers (adjectives), in nominative case: e.g. 'старый монитор', not just 'монитор'.\n"
 29    "Return PLACE as the full phrase the user said, including the preposition (e.g. 'в гараже', 'на столе').\n"
 30    "For a QUERY return PLACE as an empty string.\n"
 31    "Return only the JSON object, no additional text.\n"
 32)
 33
 34
 35def _render_template(template: str, utterance: str) -> str:
 36    """Подставляет utterance в шаблон промпта."""
 37    return template.replace("{{utterance}}", utterance)
 38
 39
 40def _cache_prompt(text: str) -> None:
 41    """Сохраняет промпт в файл кэша (фолбэк при недоступности Langfuse)."""
 42    try:
 43        os.makedirs(DATA_DIR, exist_ok=True)
 44        with open(PROMPT_CACHE_FILE, "w", encoding="utf-8") as f:
 45            f.write(text)
 46    except OSError as e:
 47        logger.warning(f"Langfuse: не удалось записать кэш промпта: {e}")
 48
 49
 50def _read_cached_prompt() -> Optional[str]:
 51    """Возвращает промпт из файла кэша или None."""
 52    try:
 53        with open(PROMPT_CACHE_FILE, encoding="utf-8") as f:
 54            return f.read()
 55    except OSError:
 56        return None
 57
 58
 59LLM_MODEL = "e.anisimov/eagent"
 60
 61
 62def _make_client():
 63    """Создаёт OpenAI-клиент для обращения к LLM (base_url/api_key из env).
 64
 65    Ключ берётся ТОЛЬКО из окружения (LLM_API_KEY, задаётся в .env /
 66    /srv/LostAndFound.env) — в коде секретов нет.
 67    """
 68    base_url = os.getenv("LLM_BASE_URL", "http://e-agent:18012/v1")
 69    api_key = os.getenv("LLM_API_KEY", "")
 70    return OpenAI(base_url=base_url, api_key=api_key)
 71
 72
 73DELETE_VERBS = (
 74    "удали",
 75    "удалить",
 76    "удалите",
 77    "убери",
 78    "уберите",
 79    "забудь",
 80    "забыть",
 81    "вычеркни",
 82    "вычеркнуть",
 83)
 84DELETE_FILLERS = ("из списка", "из базы данных", "из базы", "из записей")
 85
 86
 87def _extract_delete_thing(utterance: str) -> Optional[str]:
 88    """
 89    Извлекает название вещи из прямой DELETE-команды.
 90
 91    Парсит фразы вида 'Удали болгарка' / 'Убери из списка нож':
 92    берёт всё, что идёт после глагола удаления (с отбрасыванием
 93    служебных фраз вроде 'из списка'). Возвращает None, если
 94    команда удаления не распознана или после глагола ничего нет.
 95    """
 96    text = utterance.strip().lower()
 97    for verb in DELETE_VERBS:
 98        if text == verb:
 99            return None
100        if text.startswith(verb + " "):
101            thing = text[len(verb) :].strip()
102            for filler in DELETE_FILLERS:
103                if thing.startswith(filler + " ") or thing == filler:
104                    thing = thing[len(filler) :].strip()
105                    break
106            return thing or None
107    return None
108
109
110SEARCH_VERBS = (
111    "найди",
112    "найдите",
113    "поищи",
114    "поищите",
115    "поиск",
116)
117
118
119def _extract_search_thing(utterance: str) -> Optional[str]:
120    """
121    Извлекает поисковый запрос из фразы с ключевым словом поиска.
122
123    Парсит фразы вида 'Найди болгарку' / 'Поищи автомойку' / 'Поиск монитор':
124    берёт всё, что идёт после ключевого слова 'найди'/'найдите'/'поищи'/
125    'поищите'/'поиск'. Возвращает None, если ключевое слово не найдено
126    или после него ничего нет.
127    """
128    text = utterance.strip().lower()
129    for verb in SEARCH_VERBS:
130        if text == verb:
131            return None
132        if text.startswith(verb + " "):
133            return text[len(verb) :].strip() or None
134    return None
135
136
137ALICE_WORDS = ("алиса", "алис")
138
139
140def _strip_alice(text: str) -> str:
141    """
142    Удаляет обращение 'Алиса' из фразы.
143
144    Вырезает слово 'Алиса'/'Алис' в любом регистре и позиции, вместе с
145    прилегающей пунктуацией. Примеры:
146        'Алиса, где болгарка?' -> 'где болгарка?'
147        'алиса болгарка в кладовке' -> 'болгарка в кладовке'
148    """
149    words = text.split()
150    filtered = [
151        word for word in words if word.lower().strip(",.!?;:«»\"'()") not in ALICE_WORDS
152    ]
153    return " ".join(filtered)
154
155
156@dataclass
157class ParseResult:
158    """
159    Результат парсинга команды пользователя.
160
161    Содержит идентификатор действия и сопутствующие параметры:
162    thing (вещь), place (место).
163    """
164
165    action: str  # "insert", "update", "query", "delete" или "ping"
166    thing: str = ""
167    place: str = ""
168
169
170class CommandParser:
171    """
172    Парсер естественного языка для навык LostAndFound.
173
174    Использует LLM (GPT-OSS) для анализа
175    входящего текста и извлечения структурированных данных.
176    """
177
178    def __init__(self):
179        """Инициализирует CommandParser."""
180        self._llm_client = None
181        self._prompt = None
182
183    def cache_prompt(self):
184        """
185        Кэширует промпт из Langfuse в data/prompt.txt.
186
187        Langfuse — источник правды. Вызывается при старте приложения:
188        получает текущий промпт и сохраняет его текст, чтобы при
189        недоступности Langfuse продолжать работать с последней версии.
190        """
191        try:
192            client = get_client().get_prompt(PROMPT_NAME)
193            text = getattr(client, "prompt", None)
194            if text:
195                _cache_prompt(text)
196                logger.info(
197                    f"Langfuse: промпт '{PROMPT_NAME}' закэширован "
198                    f"(version={getattr(client, 'version', '?')})"
199                )
200        except Exception as e:
201            logger.warning(f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}")
202
203    def _get_prompt(self) -> object:
204        """
205        Возвращает шаблон промпта из Langfuse или кэша.
206
207        Сначала пытается получить промпт из Langfuse (источник правды);
208        при успехе обновляет кэш в data/prompt.txt. Если Langfuse
209        недоступен — берёт текст из кэша, а если кэша нет — из
210        PROMPT_TEMPLATE (bootstrap).
211
212        Returns:
213            object: TextPromptClient из Langfuse или строковый шаблон
214                (с плейсхолдером {{utterance}})
215        """
216        if self._prompt is None:
217            try:
218                client = get_client().get_prompt(PROMPT_NAME)
219                text = getattr(client, "prompt", None)
220                if text:
221                    _cache_prompt(text)
222                    self._prompt = client
223                    return self._prompt
224            except Exception as e:
225                logger.warning(
226                    f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}"
227                )
228            cached = _read_cached_prompt()
229            self._prompt = cached if cached is not None else PROMPT_TEMPLATE
230        return self._prompt
231
232    def _build_prompt(self, utterance: str) -> tuple[str, Optional[object]]:
233        """
234        Собирает финальный текст промпта для конкретной utterance.
235
236        Args:
237            utterance: Высказывание пользователя
238
239        Returns:
240            tuple[str, Optional[object]]: Текст промпта и объект промпта
241                Langfuse (для привязки версии к генерации) или None
242        """
243        prompt_source = self._get_prompt()
244        if isinstance(prompt_source, str):
245            return _render_template(prompt_source, utterance), None
246        compile_method = getattr(prompt_source, "compile", None)
247        if compile_method is not None:
248            try:
249                return compile_method(utterance=utterance), prompt_source
250            except Exception:
251                pass
252        return _render_template(PROMPT_TEMPLATE, utterance), None
253
254    def _call_llm(self, utterance: str) -> Optional[dict]:
255        """
256        Вызова LLM для парсинга естественного языка.
257
258        Использует GPT-OSS через OpenAI-compatible API.
259        Вызов логируется в Langfuse (обёртка langfuse.openai).
260
261        Args:
262            utterance: Оригинальное высказывание пользователя
263
264        Returns:
265            Optional[dict]: Словарь с полями COMMAND, THING, PLACE или None при ошибке
266        """
267        prompt, lf_prompt = self._build_prompt(utterance)
268
269        client = _make_client()
270
271        for attempt in range(3):
272            try:
273                kwargs = {
274                    "model": LLM_MODEL,
275                    "messages": [{"role": "user", "content": prompt}],
276                    "temperature": 0.1,
277                    "max_tokens": 256,
278                }
279                if lf_prompt is not None:
280                    kwargs["langfuse_prompt"] = lf_prompt
281                response = client.chat.completions.create(**kwargs)
282
283                text = response.choices[0].message.content.strip()
284                if text.startswith("```json"):
285                    text = text[7:]
286                if text.startswith("```"):
287                    text = text[3:]
288                if text.endswith("```"):
289                    text = text[:-3]
290                text = text.strip()
291
292                return json.loads(text)
293            except Exception as e:
294                logger.error(f"Ошибка вызова LLM (попытка {attempt + 1}/3): {e}")
295
296        return None
297
298    def parse(self, utterance: str) -> Optional[ParseResult]:
299        """
300        Анализирует высказывание пользователя и возвращает структурированный результат.
301
302        Определяет тип действия (INSERT, QUERY, DELETE, SEARCH, ping).
303        DELETE и SEARCH распознаются детерминированно по ключевым словам
304        (до обращения к LLM), INSERT и QUERY — через LLM-парсинг.
305
306        Args:
307            utterance: Оригинальное высказывание пользователя
308
309        Returns:
310            Optional[ParseResult]: Результат парсинга или None при ошибке
311        """
312        if not utterance or utterance.strip().lower() == "ping":
313            return ParseResult(action="ping")
314
315        cleaned = _strip_alice(utterance)
316
317        delete_thing = _extract_delete_thing(utterance)
318        if delete_thing is None:
319            delete_thing = _extract_delete_thing(cleaned)
320        if delete_thing is not None:
321            return ParseResult(action="delete", thing=delete_thing)
322
323        search_thing = _extract_search_thing(utterance)
324        if search_thing is None:
325            search_thing = _extract_search_thing(cleaned)
326        if search_thing is not None:
327            return ParseResult(action="search", thing=search_thing)
328
329        if not cleaned:
330            return None
331
332        llm_result = self._call_llm(cleaned)
333
334        if llm_result is None:
335            return None
336
337        command = llm_result.get("COMMAND", "").upper()
338        thing = llm_result.get("THING", "").strip()
339        place = llm_result.get("PLACE", "").strip()
340
341        if command == "INSERT":
342            if not thing or not place:
343                return None
344            return ParseResult(action="insert", thing=thing, place=place)
345        elif command == "QUERY":
346            return ParseResult(action="query", thing=thing, place=place)
347
348        return None
PROMPT_NAME = 'lostandfound-command-parser'
PROMPT_CACHE_FILE = './data/prompt.txt'
PROMPT_TEMPLATE = "Utterance: {{utterance}}\nParse it into JSON with fields COMMAND ('INSERT' or 'QUERY'), THING, and PLACE.\nDetermine if user states a location (INSERT) or asks a question (QUERY).\nReturn THING as the complete noun phrase the user said, keeping all modifiers (adjectives), in nominative case: e.g. 'старый монитор', not just 'монитор'.\nReturn PLACE as the full phrase the user said, including the preposition (e.g. 'в гараже', 'на столе').\nFor a QUERY return PLACE as an empty string.\nReturn only the JSON object, no additional text.\n"
LLM_MODEL = 'e.anisimov/eagent'
DELETE_VERBS = ('удали', 'удалить', 'удалите', 'убери', 'уберите', 'забудь', 'забыть', 'вычеркни', 'вычеркнуть')
DELETE_FILLERS = ('из списка', 'из базы данных', 'из базы', 'из записей')
SEARCH_VERBS = ('найди', 'найдите', 'поищи', 'поищите', 'поиск')
ALICE_WORDS = ('алиса', 'алис')
@dataclass
class ParseResult:
157@dataclass
158class ParseResult:
159    """
160    Результат парсинга команды пользователя.
161
162    Содержит идентификатор действия и сопутствующие параметры:
163    thing (вещь), place (место).
164    """
165
166    action: str  # "insert", "update", "query", "delete" или "ping"
167    thing: str = ""
168    place: str = ""

Результат парсинга команды пользователя.

Содержит идентификатор действия и сопутствующие параметры: thing (вещь), place (место).

ParseResult(action: str, thing: str = '', place: str = '')
action: str
thing: str = ''
place: str = ''
class CommandParser:
171class CommandParser:
172    """
173    Парсер естественного языка для навык LostAndFound.
174
175    Использует LLM (GPT-OSS) для анализа
176    входящего текста и извлечения структурированных данных.
177    """
178
179    def __init__(self):
180        """Инициализирует CommandParser."""
181        self._llm_client = None
182        self._prompt = None
183
184    def cache_prompt(self):
185        """
186        Кэширует промпт из Langfuse в data/prompt.txt.
187
188        Langfuse — источник правды. Вызывается при старте приложения:
189        получает текущий промпт и сохраняет его текст, чтобы при
190        недоступности Langfuse продолжать работать с последней версии.
191        """
192        try:
193            client = get_client().get_prompt(PROMPT_NAME)
194            text = getattr(client, "prompt", None)
195            if text:
196                _cache_prompt(text)
197                logger.info(
198                    f"Langfuse: промпт '{PROMPT_NAME}' закэширован "
199                    f"(version={getattr(client, 'version', '?')})"
200                )
201        except Exception as e:
202            logger.warning(f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}")
203
204    def _get_prompt(self) -> object:
205        """
206        Возвращает шаблон промпта из Langfuse или кэша.
207
208        Сначала пытается получить промпт из Langfuse (источник правды);
209        при успехе обновляет кэш в data/prompt.txt. Если Langfuse
210        недоступен — берёт текст из кэша, а если кэша нет — из
211        PROMPT_TEMPLATE (bootstrap).
212
213        Returns:
214            object: TextPromptClient из Langfuse или строковый шаблон
215                (с плейсхолдером {{utterance}})
216        """
217        if self._prompt is None:
218            try:
219                client = get_client().get_prompt(PROMPT_NAME)
220                text = getattr(client, "prompt", None)
221                if text:
222                    _cache_prompt(text)
223                    self._prompt = client
224                    return self._prompt
225            except Exception as e:
226                logger.warning(
227                    f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}"
228                )
229            cached = _read_cached_prompt()
230            self._prompt = cached if cached is not None else PROMPT_TEMPLATE
231        return self._prompt
232
233    def _build_prompt(self, utterance: str) -> tuple[str, Optional[object]]:
234        """
235        Собирает финальный текст промпта для конкретной utterance.
236
237        Args:
238            utterance: Высказывание пользователя
239
240        Returns:
241            tuple[str, Optional[object]]: Текст промпта и объект промпта
242                Langfuse (для привязки версии к генерации) или None
243        """
244        prompt_source = self._get_prompt()
245        if isinstance(prompt_source, str):
246            return _render_template(prompt_source, utterance), None
247        compile_method = getattr(prompt_source, "compile", None)
248        if compile_method is not None:
249            try:
250                return compile_method(utterance=utterance), prompt_source
251            except Exception:
252                pass
253        return _render_template(PROMPT_TEMPLATE, utterance), None
254
255    def _call_llm(self, utterance: str) -> Optional[dict]:
256        """
257        Вызова LLM для парсинга естественного языка.
258
259        Использует GPT-OSS через OpenAI-compatible API.
260        Вызов логируется в Langfuse (обёртка langfuse.openai).
261
262        Args:
263            utterance: Оригинальное высказывание пользователя
264
265        Returns:
266            Optional[dict]: Словарь с полями COMMAND, THING, PLACE или None при ошибке
267        """
268        prompt, lf_prompt = self._build_prompt(utterance)
269
270        client = _make_client()
271
272        for attempt in range(3):
273            try:
274                kwargs = {
275                    "model": LLM_MODEL,
276                    "messages": [{"role": "user", "content": prompt}],
277                    "temperature": 0.1,
278                    "max_tokens": 256,
279                }
280                if lf_prompt is not None:
281                    kwargs["langfuse_prompt"] = lf_prompt
282                response = client.chat.completions.create(**kwargs)
283
284                text = response.choices[0].message.content.strip()
285                if text.startswith("```json"):
286                    text = text[7:]
287                if text.startswith("```"):
288                    text = text[3:]
289                if text.endswith("```"):
290                    text = text[:-3]
291                text = text.strip()
292
293                return json.loads(text)
294            except Exception as e:
295                logger.error(f"Ошибка вызова LLM (попытка {attempt + 1}/3): {e}")
296
297        return None
298
299    def parse(self, utterance: str) -> Optional[ParseResult]:
300        """
301        Анализирует высказывание пользователя и возвращает структурированный результат.
302
303        Определяет тип действия (INSERT, QUERY, DELETE, SEARCH, ping).
304        DELETE и SEARCH распознаются детерминированно по ключевым словам
305        (до обращения к LLM), INSERT и QUERY — через LLM-парсинг.
306
307        Args:
308            utterance: Оригинальное высказывание пользователя
309
310        Returns:
311            Optional[ParseResult]: Результат парсинга или None при ошибке
312        """
313        if not utterance or utterance.strip().lower() == "ping":
314            return ParseResult(action="ping")
315
316        cleaned = _strip_alice(utterance)
317
318        delete_thing = _extract_delete_thing(utterance)
319        if delete_thing is None:
320            delete_thing = _extract_delete_thing(cleaned)
321        if delete_thing is not None:
322            return ParseResult(action="delete", thing=delete_thing)
323
324        search_thing = _extract_search_thing(utterance)
325        if search_thing is None:
326            search_thing = _extract_search_thing(cleaned)
327        if search_thing is not None:
328            return ParseResult(action="search", thing=search_thing)
329
330        if not cleaned:
331            return None
332
333        llm_result = self._call_llm(cleaned)
334
335        if llm_result is None:
336            return None
337
338        command = llm_result.get("COMMAND", "").upper()
339        thing = llm_result.get("THING", "").strip()
340        place = llm_result.get("PLACE", "").strip()
341
342        if command == "INSERT":
343            if not thing or not place:
344                return None
345            return ParseResult(action="insert", thing=thing, place=place)
346        elif command == "QUERY":
347            return ParseResult(action="query", thing=thing, place=place)
348
349        return None

Парсер естественного языка для навык LostAndFound.

Использует LLM (GPT-OSS) для анализа входящего текста и извлечения структурированных данных.

CommandParser()
179    def __init__(self):
180        """Инициализирует CommandParser."""
181        self._llm_client = None
182        self._prompt = None

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

def cache_prompt(self):
184    def cache_prompt(self):
185        """
186        Кэширует промпт из Langfuse в data/prompt.txt.
187
188        Langfuse — источник правды. Вызывается при старте приложения:
189        получает текущий промпт и сохраняет его текст, чтобы при
190        недоступности Langfuse продолжать работать с последней версии.
191        """
192        try:
193            client = get_client().get_prompt(PROMPT_NAME)
194            text = getattr(client, "prompt", None)
195            if text:
196                _cache_prompt(text)
197                logger.info(
198                    f"Langfuse: промпт '{PROMPT_NAME}' закэширован "
199                    f"(version={getattr(client, 'version', '?')})"
200                )
201        except Exception as e:
202            logger.warning(f"Langfuse: не удалось получить промпт '{PROMPT_NAME}': {e}")

Кэширует промпт из Langfuse в data/prompt.txt.

Langfuse — источник правды. Вызывается при старте приложения: получает текущий промпт и сохраняет его текст, чтобы при недоступности Langfuse продолжать работать с последней версии.

def parse(self, utterance: str) -> Optional[ParseResult]:
299    def parse(self, utterance: str) -> Optional[ParseResult]:
300        """
301        Анализирует высказывание пользователя и возвращает структурированный результат.
302
303        Определяет тип действия (INSERT, QUERY, DELETE, SEARCH, ping).
304        DELETE и SEARCH распознаются детерминированно по ключевым словам
305        (до обращения к LLM), INSERT и QUERY — через LLM-парсинг.
306
307        Args:
308            utterance: Оригинальное высказывание пользователя
309
310        Returns:
311            Optional[ParseResult]: Результат парсинга или None при ошибке
312        """
313        if not utterance or utterance.strip().lower() == "ping":
314            return ParseResult(action="ping")
315
316        cleaned = _strip_alice(utterance)
317
318        delete_thing = _extract_delete_thing(utterance)
319        if delete_thing is None:
320            delete_thing = _extract_delete_thing(cleaned)
321        if delete_thing is not None:
322            return ParseResult(action="delete", thing=delete_thing)
323
324        search_thing = _extract_search_thing(utterance)
325        if search_thing is None:
326            search_thing = _extract_search_thing(cleaned)
327        if search_thing is not None:
328            return ParseResult(action="search", thing=search_thing)
329
330        if not cleaned:
331            return None
332
333        llm_result = self._call_llm(cleaned)
334
335        if llm_result is None:
336            return None
337
338        command = llm_result.get("COMMAND", "").upper()
339        thing = llm_result.get("THING", "").strip()
340        place = llm_result.get("PLACE", "").strip()
341
342        if command == "INSERT":
343            if not thing or not place:
344                return None
345            return ParseResult(action="insert", thing=thing, place=place)
346        elif command == "QUERY":
347            return ParseResult(action="query", thing=thing, place=place)
348
349        return None

Анализирует высказывание пользователя и возвращает структурированный результат.

Определяет тип действия (INSERT, QUERY, DELETE, SEARCH, ping). DELETE и SEARCH распознаются детерминированно по ключевым словам (до обращения к LLM), INSERT и QUERY — через LLM-парсинг.

Args: utterance: Оригинальное высказывание пользователя

Returns: Optional[ParseResult]: Результат парсинга или None при ошибке