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
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 (место).
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) для анализа входящего текста и извлечения структурированных данных.
179 def __init__(self): 180 """Инициализирует CommandParser.""" 181 self._llm_client = None 182 self._prompt = None
Инициализирует CommandParser.
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 продолжать работать с последней версии.
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 при ошибке