"""AMS-агент «Погода». Назначение: по названию города вернуть короткую сводку о текущей погоде. Архитектура (разделение ответственности): * weather.py — получение данных (геокодинг + прогноз); ничего не знает про AMS; * handler.py — тонкий адаптер AMS: вызывает сервис и отдаёт ответ стримом. Модуль не выпускает исключений наружу: любая ошибка превращается в текстовый чанк. Это принципиально, потому что воркер AMS при исключении из step() прерывает диалог и не отправляет клиенту ничего. Контракт AMS: модуль экспортирует генератор step(...), который AMS вызывает по именам аргументов: step(params=..., state=..., agent=...). Отдавать нужно словари вида {"type": "chunk", "data": "..."}. """ from __future__ import annotations import importlib.util import logging import os import sys from typing import Any, Iterator from pydantic_ai import ModelSettings logger = logging.getLogger(__name__) CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) def _load_sibling(module_name: str, filename: str) -> Any: """Импортировать модуль из каталога агента. AMS загружает handler.py как отдельный модуль (не пакет), а каталог агента не входит в sys.path. Поэтому соседний модуль подключаем явно и под уникальным именем — это исключает конфликты имён с другими агентами. Модуль обязательно регистрируем в sys.modules: этого требуют dataclasses (при отложенных аннотациях) и pickle. """ spec = importlib.util.spec_from_file_location(module_name, os.path.join(CURRENT_DIR, filename)) module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module try: spec.loader.exec_module(module) except Exception: sys.modules.pop(module_name, None) # не оставляем «полумодуль» в реестре raise return module weather = _load_sibling("ams_weather_service", "weather.py") def _read_text(filename: str) -> str: """Прочитать текстовый ресурс, лежащий рядом с обработчиком.""" with open(os.path.join(CURRENT_DIR, filename), encoding="utf-8") as file: return file.read() SYSTEM_PROMPT = _read_text("system_prompt.txt") # temperature — модель только оформляет факты, без «творчества». # enable_thinking отключён: иначе Qwen тратит время на reasoning-токены, # а max_tokens ограничивает длину ответа (ответ по заданию короткий). MODEL_SETTINGS = ModelSettings( temperature=0.2, max_tokens=256, extra_body={"chat_template_kwargs": {"enable_thinking": False}}, ) def _facts(location: weather.Location, current: weather.Weather) -> str: """Факты для языковой модели. Все значения передаём явно, чтобы модель не додумывала числа. """ return ( f"Город: {location.title}\n" f"Состояние: {current.description}\n" f"Температура: {current.temperature_c} °C\n" f"Влажность: {current.humidity_pct} %\n" f"Ветер: {current.wind_ms} м/с, направление {current.wind_dir_deg}°\n" f"Осадки: {current.precipitation_mm} мм/час" ) def _run(agent: Any, params: dict[str, Any]) -> Iterator[dict[str, Any]]: """Основной сценарий: город -> сводка о погоде.""" # Запрос относится к конкретному городу, поэтому не тащим контекст прошлых. agent.history = [] agent.set_system_prompt(SYSTEM_PROMPT) try: location = weather.geocode(params["city"]) current = weather.fetch_weather(location) except weather.WeatherError as exc: logger.warning("Погода недоступна: %s", exc.detail) yield {"type": "chunk", "data": f"Не удалось получить погоду: {exc.message}."} return summary = weather.format_summary(location, current) # Языковая модель только переформулирует факты. Если она недоступна — # отдаём детерминированную сводку, чтобы ответ был в любом случае. try: with agent.run_stream_sync(_facts(location, current), model_settings=MODEL_SETTINGS) as stream: for chunk in stream.stream_text(delta=True): yield {"type": "chunk", "data": chunk} except Exception: # noqa: BLE001 — намеренно широкий: нужен гарантированный ответ logger.exception("LLM недоступен, отдаю детерминированную сводку") yield {"type": "chunk", "data": summary} def step(agent: Any, state: dict[str, Any], params: dict[str, Any]) -> Iterator[dict[str, Any]]: """Точка входа AMS. Гарантированно не поднимает исключений.""" try: yield from _run(agent, params) except Exception: # noqa: BLE001 — последний рубеж: диалог не должен падать logger.exception("Непредвиденная ошибка обработки запроса") yield {"type": "chunk", "data": "Не удалось обработать запрос, попробуйте позже."}