sources/webrtc-reviewed/src_llm/service.py

Текстовый файл из учебного комплекта, не свидетельство установленного 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")