Текстовый файл из учебного комплекта, не свидетельство установленного runtime. Вернуться к учебнику · Исходный файл
1 import asyncio 2 import json 3 import logging 4 import re 5 import threading 6 import time 7 from pathlib import Path 8 from typing import Any 9 from uuid import uuid4 10 11 import ollama 12 from nats.aio.msg import Msg 13 from pydantic import BaseModel 14 15 from src_llm.clients.local_chat import chat as local_chat 16 from src_llm.clients.ollama_chat import chat as ollama_chat 17 from src_llm.settings import STACK_SERVICE_NAME 18 from src_llm.utils.base_service import BaseService 19 from src_llm.utils.model_downloader import ensure_model_path 20 from src_shared.contracts import ( 21 ErrorInfo, 22 FinalResponseData, 23 LlmRequest, 24 LlmResponse, 25 ParamsExtractData, 26 ReviseData, 27 RoutingDecisionData, 28 ToolDecisionData, 29 ToolParamsData, 30 now_ts_ms, 31 ) 32 33 logger = logging.getLogger(STACK_SERVICE_NAME) 34 35 36 SUPPORTED_ROUTING_WORKFLOW_IDS = frozenset( 37 { 38 "where_my_flight@2.0.0", 39 "find_nearest_airport@2.0.0", 40 "free_speech@2.0.0", 41 "echo@2.0.0", 42 } 43 ) 44 45 46 class LLMService(BaseService): 47 def __init__( 48 self, 49 *, 50 service_name: str, 51 nats_url: str, 52 llm_subject: str, 53 events_subject: str, 54 ollama_url: str, 55 ollama_timeout_s: float, 56 llm_local_model: str, 57 llm_remote_model: str, 58 default_max_tokens: int, 59 llm_mode: str, 60 llm_models_dir: str, 61 ollama_model_file: str, 62 llm_context_size: int, 63 ) -> None: 64 super().__init__( 65 service_name=service_name, 66 nats_url=nats_url, 67 llm_subject=llm_subject, 68 events_subject=events_subject, 69 ) 70 self.llm_local_model = llm_local_model 71 self.llm_remote_model = llm_remote_model 72 self.default_max_tokens = int(default_max_tokens) 73 self.llm_mode = (llm_mode or "remote").strip().lower() 74 if self.llm_mode not in {"local", "remote"}: 75 raise ValueError("LLM_MODE must be either 'local' or 'remote'") 76 self.llm_models_dir = llm_models_dir 77 self.ollama_model_file = (ollama_model_file or "").strip() or None 78 self.llm_context_size = int(llm_context_size) 79 self.ollama_timeout_s = float(ollama_timeout_s) 80 self._use_local = self.llm_mode == "local" 81 self._ollama = None if self._use_local else ollama.Client(host=ollama_url, timeout=self.ollama_timeout_s) 82 self._local_model = None 83 self._inference_lock = threading.Lock() 84 self._inference_meta_lock = threading.Lock() 85 self._inference_meta_by_request: dict[str, dict[str, Any]] = {} 86 87 async def on_run(self) -> None: 88 await self._nats_logger.info( 89 f"{STACK_SERVICE_NAME} service connected (mode={self.llm_mode})" 90 ) 91 await self._nats_logger.info( 92 { 93 "mode": self.llm_mode, 94 "configured_model": self._configured_model(), 95 "llm_context_size": self.llm_context_size, 96 }, 97 name="llm_runtime_config", 98 ) 99 if self._use_local: 100 await self._nats_logger.log("command", "status_llm", {"status": "downloading"}) 101 await asyncio.to_thread( 102 ensure_model_path, 103 self.llm_local_model, 104 self.llm_models_dir, 105 self.ollama_model_file, 106 ) 107 await self._nats_logger.log("command", "status_llm", {"status": "ready"}) 108 109 def _ensure_local_model(self): 110 if self._local_model: 111 return self._local_model 112 113 model_path = ensure_model_path( 114 self.llm_local_model, 115 self.llm_models_dir, 116 self.ollama_model_file, 117 ) 118 logger.info("Loading local model from %s", model_path) 119 120 from llama_cpp import Llama 121 122 init_kwargs = { 123 "model_path": model_path, 124 "n_ctx": self.llm_context_size, 125 } 126 self._local_model = Llama(**init_kwargs) 127 return self._local_model 128 129 def on_message(self, msg: Msg) -> dict[str, Any]: 130 raw = json.loads(msg.data.decode("utf-8")) 131 try: 132 req = LlmRequest.model_validate(raw) 133 except Exception as exc: 134 resp = LlmResponse( 135 trace_id=uuid4(), 136 correlation_id=None, 137 request_id=uuid4(), 138 session_id=None, 139 ts_ms=now_ts_ms(), 140 ok=False, 141 data=None, 142 error=ErrorInfo(code="invalid_request", message=str(exc)), 143 ) 144 return json.loads(resp.model_dump_json()) 145 146 try: 147 if req.mode == "routing_decision": 148 data = self._routing_decision(req) 149 elif req.mode == "params_extract": 150 data = self._params_extract(req) 151 elif req.mode == "revise": 152 data = self._revise(req) 153 elif req.mode == "tool_decision": 154 data = self._tool_decision(req) 155 elif req.mode == "tool_params": 156 data = self._tool_params(req) 157 elif req.mode == "final_response": 158 data = self._final_response(req) 159 else: 160 raise ValueError(f"Unsupported mode: {req.mode}") 161 return json.loads(LlmResponse.from_validated_data(request=req, data=data).model_dump_json()) 162 except Exception as exc: 163 resp = LlmResponse( 164 trace_id=req.trace_id, 165 correlation_id=req.correlation_id, 166 request_id=req.request_id, 167 session_id=req.session_id, 168 ts_ms=now_ts_ms(), 169 ok=False, 170 data=None, 171 error=ErrorInfo(code="llm_failed", message=str(exc)), 172 ) 173 return json.loads(resp.model_dump_json()) 174 175 @staticmethod 176 def _routing_input_normalized(inp: dict[str, Any]) -> dict[str, Any]: 177 out = dict(inp or {}) 178 179 if not out.get("text") and isinstance(out.get("user_message"), str): 180 out["text"] = out.get("user_message") 181 182 scenarios = out.get("available_scenarios") 183 if isinstance(scenarios, list): 184 allowlist: list[str] = [] 185 for item in scenarios: 186 if not isinstance(item, dict): 187 continue 188 scenario_id = str(item.get("id") or "").strip() 189 if scenario_id: 190 allowlist.append(scenario_id) 191 if allowlist and not isinstance(out.get("allowlist_workflows"), list): 192 out["allowlist_workflows"] = allowlist 193 194 return out 195 196 @staticmethod 197 def _mode_instruction_ru(mode: str) -> str: 198 if mode == "routing_decision": 199 return ( 200 "Определи один наиболее подходящий сценарий (workflow) для пользовательского запроса. " 201 "Выбирай только из списка allowlist_workflows или available_scenarios[].id. " 202 "Учитывай русские формулировки, опечатки и неполные запросы." 203 " Текущая реплика важнее истории: вопрос о названии аэропорта и список в радиусе — " 204 "это поиск аэропортов, а не просьба построить маршрут." 205 ) 206 if mode == "params_extract": 207 return ( 208 "Извлеки параметры из сообщения пользователя по заданной спецификации. " 209 "Если данных не хватает, верни missing и не выдумывай значения." 210 ) 211 if mode == "revise": 212 return ( 213 "Проверь и исправь структуру данных. Если данных недостаточно, попроси уточнение на русском." 214 ) 215 if mode == "tool_decision": 216 return ( 217 "Выбери следующий инструмент из available_tools. " 218 "Если инструмент не нужен, верни needs_tool=false." 219 ) 220 if mode == "tool_params": 221 return ( 222 "Извлеки параметры для вызова инструмента по сообщению пользователя и контексту. " 223 "Если параметров не хватает, перечисли missing и сформулируй prompt на русском." 224 ) 225 if mode == "final_response": 226 return ( 227 "Сформируй финальный ответ пользователю на русском языке. " 228 "Используй релевантный контекст из dialog_context/context_artifacts/tool_results и общие знания модели. " 229 "Если есть неуверенность в фактах, явно укажи это. " 230 "Ответ должен быть понятным и без технических деталей внутренней системы." 231 ) 232 return "Верни корректный JSON по схеме." 233 234 def _configured_model(self) -> str: 235 return self.llm_local_model if self._use_local else self.llm_remote_model 236 237 def _set_inference_meta(self, request_id: str, meta: dict[str, Any]) -> None: 238 if not request_id: 239 return 240 with self._inference_meta_lock: 241 self._inference_meta_by_request[request_id] = dict(meta) 242 243 def _pop_inference_meta(self, request_id: str) -> dict[str, Any]: 244 if not request_id: 245 return {} 246 with self._inference_meta_lock: 247 value = self._inference_meta_by_request.pop(request_id, {}) 248 return value if isinstance(value, dict) else {} 249 250 def _request_debug_extra(self, raw_req: dict[str, Any]) -> dict[str, Any]: 251 return { 252 "llm_mode": self.llm_mode, 253 "configured_model": self._configured_model(), 254 "llm_context_size": self.llm_context_size, 255 "ollama_timeout_s": self.ollama_timeout_s, 256 } 257 258 def _llm_result_debug( 259 self, 260 payload: Any, 261 *, 262 req_mode: str | None, 263 raw_req: dict[str, Any] | None, 264 duration_ms: int, 265 ) -> Any: 266 if not isinstance(payload, dict): 267 return payload 268 out = dict(payload) 269 request_id = "" 270 if isinstance(raw_req, dict): 271 request_id = str(raw_req.get("request_id") or "").strip() 272 inference_meta = self._pop_inference_meta(request_id) 273 out["_debug"] = { 274 "duration_ms": max(0, int(duration_ms)), 275 **inference_meta, 276 } 277 return out 278 279 def _routing_decision(self, req: LlmRequest) -> RoutingDecisionData: 280 inp_raw = req.input if isinstance(req.input, dict) else {} 281 inp = self._routing_input_normalized(inp_raw) 282 text = str(inp.get("text") or "").lower() 283 allow_raw = inp.get("allowlist_workflows") 284 allowlist: list[str] = [] 285 if isinstance(allow_raw, list): 286 for item in allow_raw: 287 workflow_id = str(item).strip() 288 if workflow_id in SUPPORTED_ROUTING_WORKFLOW_IDS: 289 allowlist.append(workflow_id) 290 allowset = set(allowlist) 291 292 # Do not let history or a syntactically valid model answer redirect an 293 # explicit airport lookup to free speech. Keep the registry allowlist. 294 if "find_nearest_airport@2.0.0" in allowset and self._explicit_airport_lookup(text): 295 return RoutingDecisionData( 296 workflow_id="find_nearest_airport@2.0.0", 297 reason="Текущий запрос явно задаёт поиск ближайшего аэропорта или аэропортов в радиусе", 298 confidence=0.95, 299 ) 300 301 data = self._infer_json(req.model_copy(update={"input": inp}), RoutingDecisionData) 302 303 def _fallback() -> RoutingDecisionData: 304 flight_keywords = ["рейс", "flight", "авиарейс", "статус рейса", "где мой рейс"] 305 route_keywords = [ 306 "найди аэропорт", 307 "найти аэропорт", 308 "ближайший аэропорт", 309 "nearest airport", 310 "маршрут", 311 "построй маршрут", 312 "аэропорт рядом", 313 ] 314 if ( 315 ("where_my_flight@2.0.0" in allowset) 316 and ( 317 any(k in text for k in flight_keywords) 318 or ("su" in text and any(ch.isdigit() for ch in text)) 319 ) 320 ): 321 return RoutingDecisionData( 322 workflow_id="where_my_flight@2.0.0", 323 reason="Резервная маршрутизация по ключевым словам: рейс", 324 confidence=0.72, 325 ) 326 if ( 327 "find_nearest_airport@2.0.0" in allowset 328 and ( 329 any(k in text for k in route_keywords) 330 or ( 331 re.search(r"\bаэроп[о]?рт", text) 332 and any(k in text for k in ["найд", "найт", "ближайш", "радиус", "покажи", "дай", "называ", "назван"]) 333 ) 334 ) 335 ): 336 return RoutingDecisionData( 337 workflow_id="find_nearest_airport@2.0.0", 338 reason="Резервная маршрутизация по ключевым словам: аэропорт/маршрут", 339 confidence=0.72, 340 ) 341 if "free_speech@2.0.0" in allowset and text.strip(): 342 return RoutingDecisionData( 343 workflow_id="free_speech@2.0.0", 344 reason="Резервная маршрутизация: свободный диалог", 345 confidence=0.35, 346 ) 347 if "echo@2.0.0" in allowset: 348 return RoutingDecisionData( 349 workflow_id="echo@2.0.0", 350 reason="Резервная маршрутизация: echo", 351 confidence=0.3, 352 ) 353 if allowlist: 354 return RoutingDecisionData( 355 workflow_id=allowlist[0], 356 reason="Резервная маршрутизация: первый элемент allowlist", 357 confidence=0.3, 358 ) 359 raise ValueError("No workflow selected and allowlist is empty") 360 361 try: 362 parsed = RoutingDecisionData.model_validate(data) 363 except Exception: 364 # Be tolerant to slightly off-schema model outputs. 365 workflow_id = str( 366 data.get("workflow_id") 367 or data.get("workflow") 368 or data.get("scenario_id") 369 or "" 370 ).strip() 371 if not workflow_id: 372 return _fallback() 373 374 reason = str(data.get("reason") or "Нормализованное решение маршрутизации").strip() 375 try: 376 confidence = float(data.get("confidence", 0.5)) 377 except Exception: 378 confidence = 0.5 379 confidence = max(0.0, min(1.0, confidence)) 380 parsed = RoutingDecisionData( 381 workflow_id=workflow_id, 382 reason=reason or "Нормализованное решение маршрутизации", 383 confidence=confidence, 384 ) 385 386 normalized_id = self._normalize_routing_workflow_id(parsed.workflow_id, allowset) 387 if normalized_id and normalized_id != parsed.workflow_id: 388 parsed = parsed.model_copy(update={"workflow_id": normalized_id}) 389 390 if parsed.workflow_id not in SUPPORTED_ROUTING_WORKFLOW_IDS: 391 return _fallback() 392 if allowset and parsed.workflow_id not in allowset: 393 return _fallback() 394 return parsed 395 396 @staticmethod 397 def _explicit_airport_lookup(text: str) -> bool: 398 # Flight status, negation and general conversation remain model-routed. 399 if re.search(r"\b(?:рейс\w*|flight\w*)\b|\bне\s+(?:ищи|искать|показывай|надо|нужно)\b", text): 400 return False 401 airport = r"аэроп[о]?рт\w*" 402 nearest = re.search(rf"ближайш\w*\s+{airport}|nearest\s+airport", text) 403 radius = re.search(airport, text) and re.search(r"(?:радиус\w*|в\s+пределах)\s*[+-]?\d", text) 404 return bool(nearest or radius) 405 406 @staticmethod 407 def _normalize_routing_workflow_id(workflow_id: str, allowset: set[str]) -> str | None: 408 raw = str(workflow_id or "").strip() 409 if not raw: 410 return None 411 if raw in allowset: 412 return raw 413 414 def _norm(value: str) -> str: 415 return re.sub(r"[^a-z0-9]+", "", value.lower()) 416 417 key = _norm(raw) 418 alias_map = { 419 "wheremyflight200": "where_my_flight@2.0.0", 420 "wheremyflight": "where_my_flight@2.0.0", 421 "whereismyflight": "where_my_flight@2.0.0", 422 "flightstatuscheck": "where_my_flight@2.0.0", 423 "flightstatus": "where_my_flight@2.0.0", 424 "flightstatuslookup": "where_my_flight@2.0.0", 425 "findnearestairport200": "find_nearest_airport@2.0.0", 426 "findnearestairport": "find_nearest_airport@2.0.0", 427 "nearestairportroute": "find_nearest_airport@2.0.0", 428 "nearestairport": "find_nearest_airport@2.0.0", 429 "freespeech200": "free_speech@2.0.0", 430 "freespeech": "free_speech@2.0.0", 431 "echo200": "echo@2.0.0", 432 "echo": "echo@2.0.0", 433 } 434 mapped = alias_map.get(key) 435 if not mapped: 436 return raw 437 if allowset and mapped not in allowset: 438 return raw 439 return mapped 440 441 def _params_extract(self, req: LlmRequest) -> ParamsExtractData: 442 data = self._infer_json(req, ParamsExtractData) 443 try: 444 return ParamsExtractData.model_validate(data) 445 except Exception: 446 # Normalize flat outputs like {"city":"Moscow","radius_km":25} 447 # into strict schema {"ok","values","missing","confidence","notes"}. 448 inp = req.input if isinstance(req.input, dict) else {} 449 spec = inp.get("spec") if isinstance(inp.get("spec"), dict) else {} 450 fields = spec.get("fields") if isinstance(spec.get("fields"), dict) else {} 451 requested = [str(k) for k in fields.keys()] 452 453 values: dict[str, Any] = {} 454 for key in requested: 455 if key in data and data[key] is not None and str(data[key]).strip() != "": 456 values[key] = data[key] 457 458 # If spec is missing, fallback to all non-meta keys. 459 if not requested: 460 for key, value in data.items(): 461 if key in {"ok", "values", "missing", "confidence", "notes"}: 462 continue 463 if value is None or (isinstance(value, str) and value.strip() == ""): 464 continue 465 values[str(key)] = value 466 467 missing = [key for key in requested if key not in values] 468 confidence_raw = data.get("confidence", 0.75 if values else 0.25) 469 try: 470 confidence = float(confidence_raw) 471 except Exception: 472 confidence = 0.25 473 confidence = max(0.0, min(1.0, confidence)) 474 475 return ParamsExtractData( 476 ok=len(missing) == 0, 477 values=values, 478 missing=missing, 479 confidence=confidence, 480 notes=["normalized_from_flat_json"], 481 ) 482 483 def _revise(self, req: LlmRequest) -> ReviseData: 484 data = self._infer_json(req, ReviseData) 485 return ReviseData.model_validate(data) 486 487 def _tool_decision(self, req: LlmRequest) -> ToolDecisionData: 488 """LLM decides which tool to call based on user message and available tools.""" 489 data = self._infer_json(req, ToolDecisionData) 490 try: 491 return ToolDecisionData.model_validate(data) 492 except Exception: 493 # Normalize output 494 needs_tool = bool(data.get("needs_tool", True)) 495 tool_name = data.get("tool_name") 496 reason = str(data.get("reason") or "no reason provided").strip() 497 if not needs_tool: 498 return ToolDecisionData(needs_tool=False, tool_name=None, reason=reason) 499 if not tool_name: 500 raise ValueError("tool_name is required when needs_tool=true") 501 return ToolDecisionData(needs_tool=True, tool_name=str(tool_name), reason=reason) 502 503 def _tool_params(self, req: LlmRequest) -> ToolParamsData: 504 """LLM extracts parameters for tool execution.""" 505 data = self._infer_json(req, ToolParamsData) 506 try: 507 parsed = ToolParamsData.model_validate(data) 508 return self._postprocess_tool_params(req, parsed) 509 except Exception: 510 # Normalize output 511 extracted = data.get("extracted") or {} 512 if not isinstance(extracted, dict): 513 extracted = {} 514 missing = data.get("missing") or [] 515 if not isinstance(missing, list): 516 missing = [] 517 prompt = data.get("prompt") 518 if prompt and not isinstance(prompt, str): 519 prompt = str(prompt) 520 parsed = ToolParamsData( 521 extracted=extracted, 522 missing=[str(m) for m in missing], 523 prompt=prompt if prompt else None, 524 ) 525 return self._postprocess_tool_params(req, parsed) 526 527 def _postprocess_tool_params(self, req: LlmRequest, parsed: ToolParamsData) -> ToolParamsData: 528 inp = req.input if isinstance(req.input, dict) else {} 529 tool_name = str(inp.get("tool_name") or "").strip() 530 user_message = str(inp.get("user_message") or "").strip() 531 tool_results = inp.get("tool_results") if isinstance(inp.get("tool_results"), list) else [] 532 533 extracted = dict(parsed.extracted or {}) 534 missing = [str(m) for m in (parsed.missing or [])] 535 prompt = parsed.prompt if isinstance(parsed.prompt, str) and parsed.prompt.strip() else None 536 537 def _latest_tool_result(name: str) -> dict[str, Any] | None: 538 for item in reversed(tool_results): 539 if not isinstance(item, dict): 540 continue 541 if str(item.get("tool_name") or "").strip() != name: 542 continue 543 result = item.get("result") 544 if isinstance(result, dict): 545 return result 546 return None 547 548 def _to_float(v: Any) -> float | None: 549 if isinstance(v, (int, float)): 550 return float(v) 551 if isinstance(v, str): 552 try: 553 return float(v.strip()) 554 except Exception: 555 return None 556 return None 557 558 if tool_name == "get_flight_status": 559 # Try to recover common cases even if the model returned an empty object. 560 text = user_message 561 surname_stopwords = { 562 "где", 563 "мой", 564 "моя", 565 "мое", 566 "мои", 567 "рейс", 568 "статус", 569 "покажи", 570 "найди", 571 "please", 572 "flight", 573 "status", 574 "по", 575 "номер", 576 "номеру", 577 "фамилия", 578 "фамилию", 579 "фамилией", 580 "фамилии", 581 "пассажир", 582 "пассажира", 583 "это", 584 } 585 flight_match = re.search(r"\b([A-Za-zА-Яа-яЁё]{2,3}\s?\d{1,4})\b", text) 586 if flight_match and "flight_number" not in extracted: 587 flight_number = flight_match.group(1).replace(" ", "").upper() 588 flight_number = ( 589 flight_number 590 .replace("СУ", "SU") 591 .replace("АЭ", "AE") 592 ) 593 extracted["flight_number"] = flight_number 594 # Model sometimes puts airline code into last_name (e.g. "SU" from "SU123"). 595 if isinstance(extracted.get("last_name"), str): 596 raw_last_name = extracted["last_name"].strip().upper() 597 if raw_last_name and raw_last_name == re.sub(r"\d+", "", flight_number): 598 extracted.pop("last_name", None) 599 600 if "last_name" not in extracted and "flight_number" not in extracted: 601 # Explicit "surname" patterns from user text. 602 surname_patterns = [ 603 r"\bфамили(?:я|ю|ей|и)\s*(?:[:\-]\s*)?([A-Za-zА-Яа-яЁё-]{2,})\b", 604 r"\b([A-Za-zА-Яа-яЁё-]{2,})\b(?:\s|,\s*)(?:это\s+)?(?:моя\s+)?фамили(?:я|ю|ей|и)\b", 605 r"\b(?:моя\s+)?фамили(?:я|ю|ей|и)\s*(?:[:\-]\s*)?([A-Za-zА-Яа-яЁё-]{2,})\b", 606 ] 607 for pattern in surname_patterns: 608 match = re.search(pattern, text, flags=re.IGNORECASE) 609 if not match: 610 continue 611 candidate = str(match.group(1) or "").strip() 612 if candidate and candidate.lower() not in surname_stopwords: 613 extracted["last_name"] = candidate 614 break 615 616 if "last_name" not in extracted and "flight_number" not in extracted: 617 words = re.findall(r"[A-Za-zА-Яа-яЁё]{2,}", text) 618 candidates = [w for w in words if w.lower() not in surname_stopwords] 619 if len(candidates) == 1: 620 extracted["last_name"] = candidates[0] 621 622 if "flight_number" not in extracted and "last_name" not in extracted: 623 missing = ["flight_number_or_last_name"] 624 prompt = ( 625 "Укажите номер рейса (например, SU123) или фамилию пассажира." 626 ) 627 else: 628 missing = [] 629 prompt = None 630 631 elif tool_name == "search_airports_nearby": 632 pos = _latest_tool_result("get_current_position") or {} 633 pos_data = pos.get("data") if isinstance(pos.get("data"), dict) else {} 634 635 # Backward-compatible normalization if model emits old keys. 636 if "city" not in extracted and isinstance(pos_data.get("city"), str) and pos_data.get("city").strip(): 637 extracted["city"] = pos_data.get("city").strip() 638 if "radius_km" not in extracted: 639 extracted["radius_km"] = 50 640 641 city_ok = isinstance(extracted.get("city"), str) and extracted["city"].strip() 642 radius = _to_float(extracted.get("radius_km")) 643 if radius is None: 644 extracted["radius_km"] = 50 645 else: 646 extracted["radius_km"] = radius 647 648 if not city_ok: 649 missing = ["city"] 650 prompt = "Не удалось определить город. Укажите город, рядом с которым нужно найти аэропорт." 651 else: 652 missing = [] 653 prompt = None 654 655 elif tool_name == "build_route": 656 # Normalize legacy key names if model uses old workflow schema names. 657 key_aliases = { 658 "from_latitude": "from_lat", 659 "from_longitude": "from_lon", 660 "to_latitude": "to_lat", 661 "to_longitude": "to_lon", 662 } 663 for old_key, new_key in key_aliases.items(): 664 if new_key not in extracted and old_key in extracted: 665 extracted[new_key] = extracted.get(old_key) 666 667 pos = _latest_tool_result("get_current_position") or {} 668 pos_data = pos.get("data") if isinstance(pos.get("data"), dict) else {} 669 airports = _latest_tool_result("search_airports_nearby") or {} 670 airports_data = airports.get("data") if isinstance(airports.get("data"), dict) else {} 671 airports_list = airports_data.get("airports") if isinstance(airports_data.get("airports"), list) else [] 672 first_airport = airports_list[0] if airports_list and isinstance(airports_list[0], dict) else {} 673 674 if "from_lat" not in extracted and _to_float(pos_data.get("lat")) is not None: 675 extracted["from_lat"] = _to_float(pos_data.get("lat")) 676 if "from_lon" not in extracted and _to_float(pos_data.get("lon")) is not None: 677 extracted["from_lon"] = _to_float(pos_data.get("lon")) 678 if "to_lat" not in extracted and _to_float(first_airport.get("lat")) is not None: 679 extracted["to_lat"] = _to_float(first_airport.get("lat")) 680 if "to_lon" not in extracted and _to_float(first_airport.get("lon")) is not None: 681 extracted["to_lon"] = _to_float(first_airport.get("lon")) 682 683 required = ["from_lat", "from_lon", "to_lat", "to_lon"] 684 missing_required = [k for k in required if _to_float(extracted.get(k)) is None] 685 if missing_required: 686 missing = missing_required 687 prompt = ( 688 "Не удалось собрать координаты для маршрута. Уточните пункт отправления или выберите аэропорт." 689 ) 690 else: 691 for k in required: 692 extracted[k] = _to_float(extracted.get(k)) 693 missing = [] 694 prompt = None 695 696 return ToolParamsData(extracted=extracted, missing=missing, prompt=prompt) 697 698 def _final_response(self, req: LlmRequest) -> FinalResponseData: 699 """LLM generates final response to user.""" 700 data = self._infer_json(req, FinalResponseData) 701 try: 702 return FinalResponseData.model_validate(data) 703 except Exception: 704 response_text = data.get("response_text") or data.get("response") or "" 705 if not isinstance(response_text, str) or not response_text.strip(): 706 raise ValueError("response_text is required") 707 client_command = data.get("client_command") 708 if client_command and not isinstance(client_command, dict): 709 client_command = None 710 return FinalResponseData(response_text=str(response_text), client_command=client_command) 711 712 def _infer_json(self, req: LlmRequest, schema: type[BaseModel]) -> dict[str, Any]: 713 schema_json = schema.model_json_schema() 714 mode_hint = self._mode_instruction_ru(req.mode) 715 system = ( 716 "Ты внутренний сервис LLM. Твоя задача — вернуть ТОЛЬКО один JSON-объект.\n" 717 "Не используй markdown, не добавляй пояснения, не добавляй лишние ключи.\n" 718 f"Режим: {req.mode}\n" 719 f"Инструкция режима: {mode_hint}\n" 720 "Проверь соответствие JSON указанной схеме. Если формат нарушен — исправь и верни только валидный JSON.\n" 721 f"JSON Schema:\n{json.dumps(schema_json, ensure_ascii=False)}" 722 ) 723 user = json.dumps(req.input or {}, ensure_ascii=False) 724 725 options = dict(req.constraints or {}) 726 options.setdefault("temperature", 0.0) 727 max_tokens = int(options.pop("max_tokens", self.default_max_tokens) or self.default_max_tokens) 728 729 model = options.pop("model", None) or (self.llm_local_model if self._use_local else self.llm_remote_model) 730 731 messages = [ 732 {"role": "system", "content": system}, 733 {"role": "user", "content": user}, 734 ] 735 736 started_at = time.monotonic() 737 logger.info( 738 "infer_json start mode=%s request_id=%s session_id=%s provider=%s model=%s", 739 req.mode, 740 str(req.request_id), 741 str(req.session_id) if req.session_id else "<none>", 742 "local" if self._use_local else "remote", 743 str(model), 744 ) 745 try: 746 if self._use_local: 747 with self._inference_lock: 748 result = local_chat( 749 self._ensure_local_model(), 750 model=model, 751 messages=messages, 752 tools=[], 753 options=options, 754 max_tokens=max_tokens, 755 ) 756 else: 757 result = ollama_chat( 758 self._ollama, 759 model=model, 760 messages=messages, 761 tools=[], 762 options=options, 763 think=False, 764 ) 765 except Exception: 766 duration_ms = int((time.monotonic() - started_at) * 1000) 767 logger.exception( 768 "infer_json failed mode=%s request_id=%s session_id=%s duration_ms=%s", 769 req.mode, 770 str(req.request_id), 771 str(req.session_id) if req.session_id else "<none>", 772 duration_ms, 773 ) 774 raise 775 duration_ms = int((time.monotonic() - started_at) * 1000) 776 logger.info( 777 "infer_json done mode=%s request_id=%s duration_ms=%s effective_model=%s provider=%s", 778 req.mode, 779 str(req.request_id), 780 duration_ms, 781 str(result.get("model") or model), 782 str(result.get("provider") or ("local" if self._use_local else "remote")), 783 ) 784 785 self._set_inference_meta( 786 str(req.request_id), 787 { 788 "mode": req.mode, 789 "configured_model": self._configured_model(), 790 "effective_model": str(result.get("model") or model), 791 "provider": str(result.get("provider") or ("local" if self._use_local else "remote")), 792 }, 793 ) 794 content = (result.get("message") or {}).get("content") or "" 795 data = _parse_json_object(str(content)) 796 if not isinstance(data, dict): 797 raise ValueError("Model did not return a JSON object") 798 return data 799 800 801 def _parse_json_object(text: str) -> Any: 802 raw = (text or "").strip() 803 if not raw: 804 raise ValueError("Empty model output") 805 806 # Strip common markdown fences if the model ignored instructions. 807 if raw.startswith("```"): 808 raw = raw.lstrip("`") 809 nl = raw.find("\n") 810 raw = raw[nl + 1 :] if nl != -1 else raw 811 end = raw.rfind("```") 812 raw = raw[:end] if end != -1 else raw 813 raw = raw.strip() 814 815 dec = json.JSONDecoder() 816 817 # Fast path. 818 try: 819 obj, _ = dec.raw_decode(raw) 820 return obj 821 except json.JSONDecodeError: 822 pass 823 824 # Best-effort: find the first JSON object and parse only it (ignore trailing junk). 825 brace_positions = [i for i, ch in enumerate(raw) if ch == "{"] 826 for i in brace_positions[:50]: 827 try: 828 obj, _ = dec.raw_decode(raw[i:]) 829 return obj 830 except json.JSONDecodeError: 831 continue 832 833 raise ValueError("Invalid JSON output")