feat: агент «Погода» для AMS (Open-Meteo + met.no)
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"""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": "Не удалось обработать запрос, попробуйте позже."}
|
||||
Reference in New Issue
Block a user