Files
ams-weather/agents/custom/weather/handler.py
T

123 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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": "Не удалось обработать запрос, попробуйте позже."}