added api
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import character, chat, fitness, health, memory, pomodoro, projects, webhooks
|
||||
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, webhooks
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(homelab.router, tags=["homelab"])
|
||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
||||
api_router.include_router(character.router, tags=["character"])
|
||||
@@ -11,3 +12,4 @@ api_router.include_router(projects.router, tags=["projects"])
|
||||
api_router.include_router(memory.router, tags=["memory"])
|
||||
api_router.include_router(fitness.router, tags=["fitness"])
|
||||
api_router.include_router(webhooks.router, tags=["webhooks"])
|
||||
api_router.include_router(media.router, tags=["media"])
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import get_settings
|
||||
from app.homelab.comfyui import _use_anima
|
||||
|
||||
router = APIRouter(prefix="/homelab", tags=["homelab"])
|
||||
|
||||
|
||||
def _probe(url: str, *, timeout: float = 10.0) -> dict:
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
response = client.get(url)
|
||||
body = response.text[:500]
|
||||
return {
|
||||
"ok": response.status_code < 400,
|
||||
"status_code": response.status_code,
|
||||
"preview": body,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def homelab_status() -> dict:
|
||||
settings = get_settings()
|
||||
comfy_backend = "anima" if _use_anima(settings) else "checkpoint"
|
||||
return {
|
||||
"openmeteo": _probe(f"{settings.openmeteo_base_url.rstrip('/')}/v1/forecast?latitude=0&longitude=0¤t=temperature_2m"),
|
||||
"comfyui": _probe(f"{settings.comfyui_base_url.rstrip('/')}/system_stats"),
|
||||
"netdata": _probe(f"{settings.netdata_base_url.rstrip('/')}/api/v1/info"),
|
||||
"config": {
|
||||
"openmeteo_base_url": settings.openmeteo_base_url,
|
||||
"comfyui_base_url": settings.comfyui_base_url,
|
||||
"comfyui_backend": comfy_backend,
|
||||
"comfyui_unet": settings.comfyui_unet,
|
||||
"netdata_base_url": settings.netdata_base_url,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/media", tags=["media"])
|
||||
|
||||
|
||||
@router.get("/generated/{filename}")
|
||||
def get_generated_image(filename: str) -> FileResponse:
|
||||
if ".." in filename or "/" in filename or "\\" in filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
settings = get_settings()
|
||||
path = Path(settings.generated_media_dir) / filename
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return FileResponse(path, media_type="image/png")
|
||||
@@ -19,6 +19,10 @@ TOOLS_INSTRUCTIONS = """
|
||||
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
|
||||
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
|
||||
- Никогда не пиши «ожидаю ответа от системы».
|
||||
- В текстовых ответах пользователю не используй эмодзи.
|
||||
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
|
||||
- Утренний брифинг (погода + новости) → get_morning_briefing.
|
||||
- Картинки: generate_image (ComfyUI Anima) — промпт на английском, booru-теги + короткое описание; не злоупотребляй.
|
||||
""".strip()
|
||||
|
||||
DEFAULT_CARD: dict[str, Any] = {
|
||||
|
||||
@@ -76,6 +76,8 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
||||
"lookup_food",
|
||||
"lookup_exercise",
|
||||
"calc_fitness_targets",
|
||||
"get_weather",
|
||||
"get_morning_briefing",
|
||||
})
|
||||
|
||||
|
||||
@@ -190,6 +192,10 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
||||
state = "вкл" if r.get("enabled") else "выкл"
|
||||
return f"💪 **Напоминание {r.get('kind')}** · {state}"
|
||||
|
||||
if tool_name == "generate_image" and data.get("ok"):
|
||||
url = data.get("url", "")
|
||||
return f"🎨 **Картинка готова**\n\n"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ from app.chat.notices import (
|
||||
format_tool_notice,
|
||||
)
|
||||
from app.fitness.context import format_fitness_context, get_fitness_snapshot
|
||||
from app.homelab.context import format_datetime_context
|
||||
from app.homelab.openmeteo import format_weather_snapshot
|
||||
from app.memory.context import (
|
||||
format_identity_hint,
|
||||
format_memory_context,
|
||||
@@ -64,8 +66,10 @@ class ChatService:
|
||||
projects_snapshot = get_projects_snapshot(self.db)
|
||||
return (
|
||||
f"{self.character.get_system_prompt()}\n\n"
|
||||
f"{format_datetime_context(self.db)}\n\n"
|
||||
f"{format_memory_context(memory_snapshot)}\n\n"
|
||||
f"{format_fitness_context(fitness_snapshot)}\n\n"
|
||||
f"{format_weather_snapshot()}\n\n"
|
||||
f"{format_pomodoro_context(status)}\n\n"
|
||||
f"{format_projects_context(projects_snapshot)}"
|
||||
)
|
||||
|
||||
@@ -40,6 +40,54 @@ class Settings(BaseSettings):
|
||||
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
|
||||
fitness_reminders_enabled: bool = True
|
||||
|
||||
openmeteo_base_url: str = "http://192.168.1.109:8085"
|
||||
weather_lat: float = 59.9343
|
||||
weather_lon: float = 30.3351
|
||||
weather_location_name: str = "Санкт-Петербург"
|
||||
weather_cache_sec: int = 300
|
||||
|
||||
news_rss_urls: str = (
|
||||
"https://habr.com/ru/rss/all/all/,"
|
||||
"https://www.reddit.com/r/programming/.rss"
|
||||
)
|
||||
news_cache_sec: int = 1800
|
||||
news_max_items: int = 7
|
||||
|
||||
morning_digest_enabled: bool = True
|
||||
morning_digest_hour: int = 8
|
||||
morning_digest_minute: int = 0
|
||||
|
||||
comfyui_base_url: str = "http://192.168.1.109:8188"
|
||||
comfyui_enabled: bool = True
|
||||
# Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty
|
||||
comfyui_checkpoint: str = ""
|
||||
comfyui_unet: str = "anima-preview3-base.safetensors"
|
||||
comfyui_clip: str = "qwen_3_06b_base.safetensors"
|
||||
comfyui_vae: str = "qwen_image_vae.safetensors"
|
||||
comfyui_style_lora: str = "anima-preview-3-masterpieces-v5.safetensors"
|
||||
comfyui_style_lora_weight: float = 0.7
|
||||
comfyui_steps: int = 30
|
||||
comfyui_cfg: float = 4.0
|
||||
comfyui_sampler: str = "er_sde"
|
||||
comfyui_scheduler: str = "simple"
|
||||
comfyui_width: int = 1024
|
||||
comfyui_height: int = 720
|
||||
comfyui_negative_prompt: str = (
|
||||
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
|
||||
)
|
||||
comfyui_poll_interval_sec: float = 2.0
|
||||
comfyui_timeout_sec: float = 180.0
|
||||
comfyui_rofl_enabled: bool = True
|
||||
comfyui_rofl_max_per_day: int = 1
|
||||
comfyui_rofl_probability: float = 0.15
|
||||
comfyui_rofl_min_interval_hours: int = 12
|
||||
generated_media_dir: str = "./data/generated"
|
||||
|
||||
netdata_base_url: str = "http://host.docker.internal:19999"
|
||||
netdata_public_url: str = ""
|
||||
netdata_alerts_enabled: bool = True
|
||||
netdata_poll_interval_sec: int = 120
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
@@ -52,6 +100,10 @@ class Settings(BaseSettings):
|
||||
def gitea_configured(self) -> bool:
|
||||
return bool(self.gitea_token)
|
||||
|
||||
@property
|
||||
def news_rss_urls_list(self) -> list[str]:
|
||||
return [u.strip() for u in self.news_rss_urls.split(",") if u.strip()]
|
||||
|
||||
def load_system_prompt(self) -> str:
|
||||
path = Path(self.system_prompt_path)
|
||||
if path.is_file():
|
||||
|
||||
@@ -215,6 +215,16 @@ class FitnessReminder(Base):
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class AssistantState(Base):
|
||||
__tablename__ = "assistant_state"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class WorkItem(Base):
|
||||
__tablename__ = "work_items"
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import asyncio
|
||||
import random
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
ANIMA_QUALITY_PREFIX = "masterpiece, best quality, score_7, anime"
|
||||
ANIMA_DEFAULT_NEGATIVE = (
|
||||
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
|
||||
)
|
||||
|
||||
ROFL_PROMPTS = [
|
||||
f"{ANIMA_QUALITY_PREFIX}, confused cat in tiny business suit, server room, meme, chibi",
|
||||
f"{ANIMA_QUALITY_PREFIX}, potato with sunglasses on skateboard, absurd cartoon, silly",
|
||||
f"{ANIMA_QUALITY_PREFIX}, astronaut watering houseplant on the moon, wholesome, cute",
|
||||
f"{ANIMA_QUALITY_PREFIX}, rubber duck as judge at programming contest, comic style",
|
||||
f"{ANIMA_QUALITY_PREFIX}, llama DJ at house party, neon lights, party, silly",
|
||||
]
|
||||
|
||||
|
||||
def _use_anima(settings) -> bool:
|
||||
return bool(settings.comfyui_unet.strip()) and not settings.comfyui_checkpoint.strip()
|
||||
|
||||
|
||||
def _build_anima_workflow(
|
||||
positive: str,
|
||||
negative: str,
|
||||
seed: int,
|
||||
settings,
|
||||
) -> dict[str, Any]:
|
||||
workflow: dict[str, Any] = {
|
||||
"44": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {"unet_name": settings.comfyui_unet, "weight_dtype": "default"},
|
||||
},
|
||||
"45": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": settings.comfyui_clip,
|
||||
"type": "stable_diffusion",
|
||||
"device": "default",
|
||||
},
|
||||
},
|
||||
"15": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {"vae_name": settings.comfyui_vae},
|
||||
},
|
||||
"28": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {
|
||||
"width": settings.comfyui_width,
|
||||
"height": settings.comfyui_height,
|
||||
"batch_size": 1,
|
||||
},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": ["45", 0]},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["45", 0]},
|
||||
},
|
||||
"19": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["44", 0],
|
||||
"positive": ["11", 0],
|
||||
"negative": ["12", 0],
|
||||
"latent_image": ["28", 0],
|
||||
"seed": seed,
|
||||
"steps": settings.comfyui_steps,
|
||||
"cfg": settings.comfyui_cfg,
|
||||
"sampler_name": settings.comfyui_sampler,
|
||||
"scheduler": settings.comfyui_scheduler,
|
||||
"denoise": 1.0,
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["19", 0], "vae": ["15", 0]},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "assistant", "images": ["8", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
lora = settings.comfyui_style_lora.strip()
|
||||
if lora:
|
||||
workflow["46"] = {
|
||||
"class_type": "LoraLoader",
|
||||
"inputs": {
|
||||
"lora_name": lora,
|
||||
"model": ["44", 0],
|
||||
"clip": ["45", 0],
|
||||
"strength_model": settings.comfyui_style_lora_weight,
|
||||
"strength_clip": settings.comfyui_style_lora_weight,
|
||||
},
|
||||
}
|
||||
workflow["19"]["inputs"]["model"] = ["46", 0]
|
||||
workflow["11"]["inputs"]["clip"] = ["46", 1]
|
||||
workflow["12"]["inputs"]["clip"] = ["46", 1]
|
||||
|
||||
return workflow
|
||||
|
||||
|
||||
def _build_checkpoint_workflow(
|
||||
positive: str,
|
||||
negative: str,
|
||||
seed: int,
|
||||
settings,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": settings.comfyui_checkpoint},
|
||||
},
|
||||
"5": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {
|
||||
"width": settings.comfyui_width,
|
||||
"height": settings.comfyui_height,
|
||||
"batch_size": 1,
|
||||
},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": positive, "clip": ["4", 1]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": negative, "clip": ["4", 1]},
|
||||
},
|
||||
"10": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0],
|
||||
"seed": seed,
|
||||
"steps": settings.comfyui_steps,
|
||||
"cfg": settings.comfyui_cfg,
|
||||
"sampler_name": settings.comfyui_sampler,
|
||||
"scheduler": settings.comfyui_scheduler,
|
||||
"denoise": 1.0,
|
||||
},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["10", 0], "vae": ["4", 2]},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "assistant", "images": ["8", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_workflow(positive: str, negative: str, seed: int, settings) -> dict[str, Any]:
|
||||
if _use_anima(settings):
|
||||
return _build_anima_workflow(positive, negative, seed, settings)
|
||||
return _build_checkpoint_workflow(positive, negative, seed, settings)
|
||||
|
||||
|
||||
def _wrap_positive_prompt(prompt: str, settings) -> str:
|
||||
text = prompt.strip()
|
||||
if not text:
|
||||
return text
|
||||
if _use_anima(settings) and ANIMA_QUALITY_PREFIX.lower() not in text.lower():
|
||||
return f"{ANIMA_QUALITY_PREFIX}, {text}"
|
||||
return text
|
||||
|
||||
|
||||
class ComfyUIClient:
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self.base_url = settings.comfyui_base_url.rstrip("/")
|
||||
self.enabled = settings.comfyui_enabled
|
||||
self.settings = settings
|
||||
self.output_dir = Path(settings.generated_media_dir)
|
||||
self.poll_interval = settings.comfyui_poll_interval_sec
|
||||
self.timeout = settings.comfyui_timeout_sec
|
||||
|
||||
def _default_negative(self) -> str:
|
||||
if _use_anima(self.settings):
|
||||
return self.settings.comfyui_negative_prompt or ANIMA_DEFAULT_NEGATIVE
|
||||
return self.settings.comfyui_negative_prompt
|
||||
|
||||
def _ensure_output_dir(self) -> None:
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def generate_image(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
negative_prompt: str | None = None,
|
||||
seed: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not self.enabled:
|
||||
return {"ok": False, "error": "ComfyUI отключён (COMFYUI_ENABLED=false)"}
|
||||
|
||||
if not _use_anima(self.settings) and not self.settings.comfyui_checkpoint.strip():
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "Не задан COMFYUI_UNET (Anima) или COMFYUI_CHECKPOINT",
|
||||
}
|
||||
|
||||
self._ensure_output_dir()
|
||||
seed = seed if seed is not None else random.randint(1, 2**31 - 1)
|
||||
positive = _wrap_positive_prompt(prompt, self.settings)
|
||||
negative = negative_prompt or self._default_negative()
|
||||
workflow = _build_workflow(positive, negative, seed, self.settings)
|
||||
client_id = str(uuid.uuid4())
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/prompt",
|
||||
json={"prompt": workflow, "client_id": client_id},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
return {"ok": False, "error": f"ComfyUI prompt error: {response.text[:300]}"}
|
||||
prompt_id = response.json().get("prompt_id")
|
||||
if not prompt_id:
|
||||
return {"ok": False, "error": "ComfyUI не вернул prompt_id"}
|
||||
|
||||
elapsed = 0.0
|
||||
while elapsed < self.timeout:
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
elapsed += self.poll_interval
|
||||
hist_resp = await client.get(f"{self.base_url}/history/{prompt_id}")
|
||||
if hist_resp.status_code != 200:
|
||||
continue
|
||||
history = hist_resp.json()
|
||||
if prompt_id not in history:
|
||||
continue
|
||||
entry = history[prompt_id]
|
||||
status = (entry.get("status") or {}).get("status_str")
|
||||
if status == "error":
|
||||
msgs = entry.get("status", {}).get("messages", [])
|
||||
return {"ok": False, "error": f"ComfyUI workflow error: {msgs}"}
|
||||
|
||||
outputs = entry.get("outputs") or {}
|
||||
for node_output in outputs.values():
|
||||
images = node_output.get("images") or []
|
||||
if not images:
|
||||
continue
|
||||
image_info = images[0]
|
||||
view_params = {
|
||||
"filename": image_info["filename"],
|
||||
"subfolder": image_info.get("subfolder", ""),
|
||||
"type": image_info.get("type", "output"),
|
||||
}
|
||||
img_resp = await client.get(f"{self.base_url}/view", params=view_params)
|
||||
if img_resp.status_code != 200:
|
||||
continue
|
||||
filename = f"{uuid.uuid4().hex}.png"
|
||||
out_path = self.output_dir / filename
|
||||
out_path.write_bytes(img_resp.content)
|
||||
return {
|
||||
"ok": True,
|
||||
"filename": filename,
|
||||
"url": f"/api/v1/media/generated/{filename}",
|
||||
"prompt": positive,
|
||||
"backend": "anima" if _use_anima(self.settings) else "checkpoint",
|
||||
}
|
||||
|
||||
return {"ok": False, "error": f"Таймаут генерации ({self.timeout}s)"}
|
||||
|
||||
def random_rofl_prompt(self) -> str:
|
||||
return random.choice(ROFL_PROMPTS)
|
||||
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.memory.service import MemoryService
|
||||
|
||||
WEEKDAY_RU = (
|
||||
"понедельник",
|
||||
"вторник",
|
||||
"среда",
|
||||
"четверг",
|
||||
"пятница",
|
||||
"суббота",
|
||||
"воскресенье",
|
||||
)
|
||||
|
||||
DEFAULT_TIMEZONE = "Europe/Moscow"
|
||||
|
||||
|
||||
def resolve_timezone(db: Session) -> str:
|
||||
profile = MemoryService(db).get_profile()
|
||||
tz = (profile.get("timezone") or "").strip()
|
||||
return tz or DEFAULT_TIMEZONE
|
||||
|
||||
|
||||
def format_datetime_context(db: Session) -> str:
|
||||
tz_name = resolve_timezone(db)
|
||||
try:
|
||||
tz = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
tz = ZoneInfo(DEFAULT_TIMEZONE)
|
||||
tz_name = DEFAULT_TIMEZONE
|
||||
|
||||
now = datetime.now(tz)
|
||||
weekday = WEEKDAY_RU[now.weekday()]
|
||||
lines = [
|
||||
"[Текущее время]",
|
||||
f"Сейчас: {now.strftime('%Y-%m-%d %H:%M')} ({weekday}), часовой пояс {tz_name}.",
|
||||
"Учитывай время при ответах о «сегодня», «утром», «вечером» и расписании.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,54 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.homelab.openmeteo import OpenMeteoClient, format_weather_snapshot
|
||||
from app.homelab.rss import RssClient
|
||||
|
||||
|
||||
def build_morning_digest(db: Session, *, include_news: bool = True) -> str:
|
||||
del db # timezone resolved via weather client / profile in future extensions
|
||||
weather_client = OpenMeteoClient()
|
||||
weather = weather_client.fetch_current_and_hourly(hours_ahead=12)
|
||||
|
||||
lines = ["🌤 **Утренний дайджест**", ""]
|
||||
|
||||
if weather.get("ok"):
|
||||
cur = weather.get("current") or {}
|
||||
lines.append(
|
||||
f"**Погода ({weather.get('location')})**: "
|
||||
f"{cur.get('temperature_c')}°C, {cur.get('conditions')}, "
|
||||
f"ветер {cur.get('wind_speed_kmh')} км/ч."
|
||||
)
|
||||
lines.append(weather_client.rain_summary(hours_ahead=12))
|
||||
else:
|
||||
lines.append(f"**Погода**: недоступна ({weather.get('error', 'ошибка')}).")
|
||||
|
||||
if include_news:
|
||||
headlines = RssClient().fetch_headlines(limit=7)
|
||||
lines.append("")
|
||||
if headlines:
|
||||
lines.append("**Новости:**")
|
||||
for item in headlines:
|
||||
title = item.get("title", "")
|
||||
link = item.get("link", "")
|
||||
source = item.get("source", "")
|
||||
if link:
|
||||
lines.append(f"- [{title}]({link}) — {source}")
|
||||
else:
|
||||
lines.append(f"- {title} — {source}")
|
||||
else:
|
||||
lines.append("**Новости**: ленты временно недоступны.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_weather_briefing(hours_ahead: int = 12, include_news: bool = False) -> dict:
|
||||
client = OpenMeteoClient()
|
||||
weather = client.fetch_current_and_hourly(hours_ahead=hours_ahead)
|
||||
result = {
|
||||
"weather": weather,
|
||||
"rain_summary": client.rain_summary(hours_ahead=hours_ahead) if weather.get("ok") else "",
|
||||
"context": format_weather_snapshot(weather),
|
||||
}
|
||||
if include_news:
|
||||
result["news"] = RssClient().fetch_headlines(limit=7)
|
||||
return result
|
||||
@@ -0,0 +1,70 @@
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.homelab.netdata import NetdataClient
|
||||
from app.homelab.state import get_state, set_state
|
||||
|
||||
ALERT_COOLDOWN_SEC = 1800
|
||||
|
||||
|
||||
def _alarm_key(alarm: dict[str, Any]) -> str:
|
||||
return f"{alarm.get('name')}:{alarm.get('status')}"
|
||||
|
||||
|
||||
def check_netdata_alerts(db: Session) -> list[str]:
|
||||
settings = get_settings()
|
||||
if not settings.netdata_alerts_enabled:
|
||||
return []
|
||||
|
||||
result = NetdataClient().fetch_alarms()
|
||||
if not result.get("ok"):
|
||||
return []
|
||||
|
||||
alarms = result.get("alarms") or []
|
||||
significant = [
|
||||
a for a in alarms
|
||||
if (a.get("status") or "").lower() in ("warning", "critical", "raised")
|
||||
]
|
||||
if not significant:
|
||||
return []
|
||||
|
||||
prev_raw = get_state(db, "netdata_alarm_hashes") or "{}"
|
||||
try:
|
||||
prev_map: dict[str, float] = json.loads(prev_raw)
|
||||
except json.JSONDecodeError:
|
||||
prev_map = {}
|
||||
|
||||
now = time.time()
|
||||
notices: list[str] = []
|
||||
new_map = dict(prev_map)
|
||||
|
||||
for alarm in significant:
|
||||
key = _alarm_key(alarm)
|
||||
digest = hashlib.sha256(json.dumps(alarm, sort_keys=True).encode()).hexdigest()[:16]
|
||||
state_key = f"netdata:{key}:{digest}"
|
||||
last_sent = prev_map.get(state_key, 0)
|
||||
if now - last_sent < ALERT_COOLDOWN_SEC:
|
||||
continue
|
||||
|
||||
host = alarm.get("host") or "server"
|
||||
value = alarm.get("value_string") or ""
|
||||
info = alarm.get("info") or alarm.get("name") or "алерт"
|
||||
status = alarm.get("status") or "alert"
|
||||
link = settings.netdata_public_url
|
||||
link_part = f" [Netdata]({link})" if link else ""
|
||||
notices.append(
|
||||
f"⚠️ **Netdata** · {host}: {info} — {status}"
|
||||
+ (f" ({value})" if value else "")
|
||||
+ link_part
|
||||
)
|
||||
new_map[state_key] = now
|
||||
|
||||
if notices:
|
||||
set_state(db, "netdata_alarm_hashes", json.dumps(new_map))
|
||||
|
||||
return notices
|
||||
@@ -0,0 +1,53 @@
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class NetdataClient:
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self.base_url = settings.netdata_base_url.rstrip("/")
|
||||
self.enabled = settings.netdata_alerts_enabled
|
||||
|
||||
def fetch_alarms(self) -> dict[str, Any]:
|
||||
if not self.enabled:
|
||||
return {"ok": False, "error": "Netdata alerts disabled", "alarms": []}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=15.0) as client:
|
||||
response = client.get(f"{self.base_url}/api/v1/alarms", params={"all": "true"})
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc), "alarms": []}
|
||||
|
||||
alarms_raw = data.get("alarms") or {}
|
||||
alarms: list[dict[str, Any]] = []
|
||||
if isinstance(alarms_raw, dict):
|
||||
for name, info in alarms_raw.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
status = (info.get("status") or "").lower()
|
||||
if status in ("clear", "undefined", "uninitialized", ""):
|
||||
continue
|
||||
alarms.append({
|
||||
"name": name,
|
||||
"status": status,
|
||||
"value_string": info.get("value_string") or info.get("value") or "",
|
||||
"chart": info.get("chart") or "",
|
||||
"host": info.get("hostname") or info.get("host") or "localhost",
|
||||
"info": info.get("info") or "",
|
||||
})
|
||||
|
||||
return {"ok": True, "alarms": alarms}
|
||||
|
||||
def fetch_info(self) -> dict[str, Any]:
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
response = client.get(f"{self.base_url}/api/v1/info")
|
||||
response.raise_for_status()
|
||||
return {"ok": True, "info": response.json()}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import ChatSession, Message
|
||||
|
||||
|
||||
def post_chat_notice(content: str) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
session = db.scalar(
|
||||
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
|
||||
)
|
||||
if not session:
|
||||
session = ChatSession(title="Уведомления")
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
db.add(Message(session_id=session.id, role="notice", content=content))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,142 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
WEATHER_CODES: dict[int, str] = {
|
||||
0: "ясно",
|
||||
1: "преимущественно ясно",
|
||||
2: "переменная облачность",
|
||||
3: "пасмурно",
|
||||
45: "туман",
|
||||
48: "изморозь",
|
||||
51: "морось",
|
||||
53: "морось",
|
||||
55: "морось",
|
||||
61: "дождь",
|
||||
63: "дождь",
|
||||
65: "сильный дождь",
|
||||
71: "снег",
|
||||
73: "снег",
|
||||
75: "сильный снег",
|
||||
80: "ливень",
|
||||
81: "ливень",
|
||||
82: "сильный ливень",
|
||||
95: "гроза",
|
||||
96: "гроза с градом",
|
||||
99: "гроза с градом",
|
||||
}
|
||||
|
||||
_cache: dict[str, Any] = {"data": None, "expires_at": 0.0}
|
||||
|
||||
|
||||
class OpenMeteoClient:
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self.base_url = settings.openmeteo_base_url.rstrip("/")
|
||||
self.lat = settings.weather_lat
|
||||
self.lon = settings.weather_lon
|
||||
self.location_name = settings.weather_location_name
|
||||
self.cache_ttl = settings.weather_cache_sec
|
||||
|
||||
def _fetch_raw(self) -> dict[str, Any]:
|
||||
now = time.time()
|
||||
if _cache["data"] and now < _cache["expires_at"]:
|
||||
return _cache["data"]
|
||||
|
||||
params = {
|
||||
"latitude": self.lat,
|
||||
"longitude": self.lon,
|
||||
"current": (
|
||||
"temperature_2m,apparent_temperature,relative_humidity_2m,"
|
||||
"precipitation,weather_code,wind_speed_10m"
|
||||
),
|
||||
"hourly": "temperature_2m,precipitation_probability,precipitation,weather_code",
|
||||
"timezone": "auto",
|
||||
"forecast_days": 2,
|
||||
}
|
||||
with httpx.Client(timeout=15.0) as client:
|
||||
response = client.get(f"{self.base_url}/v1/forecast", params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
_cache["data"] = data
|
||||
_cache["expires_at"] = now + self.cache_ttl
|
||||
return data
|
||||
|
||||
def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]:
|
||||
try:
|
||||
raw = self._fetch_raw()
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc), "location": self.location_name}
|
||||
|
||||
current = raw.get("current") or {}
|
||||
hourly = raw.get("hourly") or {}
|
||||
times = hourly.get("time") or []
|
||||
limit = min(hours_ahead, len(times))
|
||||
hourly_slice = []
|
||||
for i in range(limit):
|
||||
hourly_slice.append({
|
||||
"time": times[i],
|
||||
"temperature_c": hourly.get("temperature_2m", [None])[i],
|
||||
"precipitation_mm": hourly.get("precipitation", [None])[i],
|
||||
"precipitation_probability": hourly.get("precipitation_probability", [None])[i],
|
||||
"weather_code": hourly.get("weather_code", [None])[i],
|
||||
})
|
||||
|
||||
code = current.get("weather_code")
|
||||
return {
|
||||
"ok": True,
|
||||
"location": self.location_name,
|
||||
"current": {
|
||||
"time": current.get("time"),
|
||||
"temperature_c": current.get("temperature_2m"),
|
||||
"apparent_temperature_c": current.get("apparent_temperature"),
|
||||
"humidity_pct": current.get("relative_humidity_2m"),
|
||||
"precipitation_mm": current.get("precipitation"),
|
||||
"wind_speed_kmh": current.get("wind_speed_10m"),
|
||||
"weather_code": code,
|
||||
"conditions": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно",
|
||||
},
|
||||
"hourly": hourly_slice,
|
||||
}
|
||||
|
||||
def rain_summary(self, hours_ahead: int = 12) -> str:
|
||||
data = self.fetch_current_and_hourly(hours_ahead=hours_ahead)
|
||||
if not data.get("ok"):
|
||||
return f"Погода недоступна: {data.get('error', 'ошибка')}"
|
||||
|
||||
rainy_hours = []
|
||||
for hour in data.get("hourly") or []:
|
||||
prob = hour.get("precipitation_probability")
|
||||
precip = hour.get("precipitation_mm") or 0
|
||||
if (prob is not None and prob >= 40) or precip > 0:
|
||||
time_str = (hour.get("time") or "")[11:16]
|
||||
rainy_hours.append(f"{time_str} ({prob}% вероятность, {precip} мм)")
|
||||
|
||||
if rainy_hours:
|
||||
return "Ожидаются осадки: " + ", ".join(rainy_hours[:6])
|
||||
return "Существенных осадков в ближайшие часы не ожидается."
|
||||
|
||||
|
||||
def format_weather_snapshot(data: dict[str, Any] | None = None) -> str:
|
||||
client = OpenMeteoClient()
|
||||
snapshot = data if data is not None else client.fetch_current_and_hourly(hours_ahead=6)
|
||||
|
||||
lines = ["[Погода]"]
|
||||
if not snapshot.get("ok"):
|
||||
lines.append(f"Данные недоступны ({snapshot.get('error', 'ошибка')}).")
|
||||
lines.append("Для точного ответа вызови get_weather.")
|
||||
return "\n".join(lines)
|
||||
|
||||
cur = snapshot.get("current") or {}
|
||||
lines.append(
|
||||
f"{snapshot.get('location')}: {cur.get('temperature_c')}°C "
|
||||
f"(ощущается {cur.get('apparent_temperature_c')}°C), "
|
||||
f"{cur.get('conditions')}, ветер {cur.get('wind_speed_kmh')} км/ч."
|
||||
)
|
||||
lines.append(client.rain_summary(hours_ahead=6))
|
||||
lines.append("Вопросы «что на улице» / «будет ли дождь» — get_weather.")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,64 @@
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import feedparser
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_cache: dict[str, Any] = {"items": [], "expires_at": 0.0}
|
||||
|
||||
|
||||
class RssClient:
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self.urls = settings.news_rss_urls_list
|
||||
self.cache_ttl = settings.news_cache_sec
|
||||
self.max_items = settings.news_max_items
|
||||
|
||||
def _fetch_feed(self, url: str) -> list[dict[str, str]]:
|
||||
headers = {"User-Agent": "HomeAIAssistant/1.0 (+https://assistant.grigowashere.ru)"}
|
||||
with httpx.Client(timeout=20.0, headers=headers, follow_redirects=True) as client:
|
||||
response = client.get(url)
|
||||
response.raise_for_status()
|
||||
parsed = feedparser.parse(response.content)
|
||||
|
||||
source = urlparse(url).netloc or url
|
||||
items: list[dict[str, str]] = []
|
||||
for entry in parsed.entries[: self.max_items]:
|
||||
link = (entry.get("link") or "").strip()
|
||||
title = (entry.get("title") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
items.append({
|
||||
"title": title,
|
||||
"link": link,
|
||||
"source": source,
|
||||
"published": (entry.get("published") or entry.get("updated") or "").strip(),
|
||||
})
|
||||
return items
|
||||
|
||||
def fetch_headlines(self, limit: int | None = None) -> list[dict[str, str]]:
|
||||
now = time.time()
|
||||
if _cache["items"] and now < _cache["expires_at"]:
|
||||
items = _cache["items"]
|
||||
else:
|
||||
merged: list[dict[str, str]] = []
|
||||
seen_links: set[str] = set()
|
||||
for url in self.urls:
|
||||
try:
|
||||
for item in self._fetch_feed(url):
|
||||
link = item.get("link") or item["title"]
|
||||
if link in seen_links:
|
||||
continue
|
||||
seen_links.add(link)
|
||||
merged.append(item)
|
||||
except Exception:
|
||||
continue
|
||||
_cache["items"] = merged
|
||||
_cache["expires_at"] = now + self.cache_ttl
|
||||
items = merged
|
||||
|
||||
cap = limit or self.max_items
|
||||
return items[:cap]
|
||||
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import AssistantState
|
||||
|
||||
|
||||
def get_state(db: Session, key: str) -> str | None:
|
||||
row = db.get(AssistantState, key)
|
||||
return row.value if row else None
|
||||
|
||||
|
||||
def set_state(db: Session, key: str, value: str) -> None:
|
||||
row = db.get(AssistantState, key)
|
||||
now = datetime.now(timezone.utc)
|
||||
if row:
|
||||
row.value = value
|
||||
row.updated_at = now
|
||||
else:
|
||||
db.add(AssistantState(key=key, value=value, updated_at=now))
|
||||
db.commit()
|
||||
@@ -0,0 +1,130 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.base import SessionLocal
|
||||
from app.homelab.comfyui import ComfyUIClient
|
||||
from app.homelab.context import resolve_timezone
|
||||
from app.homelab.digest import build_morning_digest
|
||||
from app.homelab.monitoring import check_netdata_alerts
|
||||
from app.homelab.notices import post_chat_notice
|
||||
from app.homelab.state import get_state, set_state
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WATCH_INTERVAL_SEC = 60
|
||||
_netdata_tick = 0
|
||||
|
||||
|
||||
async def homelab_watcher_loop() -> None:
|
||||
global _netdata_tick
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(WATCH_INTERVAL_SEC)
|
||||
await _tick_morning_digest()
|
||||
await _tick_rofl()
|
||||
settings = get_settings()
|
||||
_netdata_tick += WATCH_INTERVAL_SEC
|
||||
if _netdata_tick >= settings.netdata_poll_interval_sec:
|
||||
_netdata_tick = 0
|
||||
await _tick_netdata()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Homelab watcher error")
|
||||
|
||||
|
||||
async def _tick_morning_digest() -> None:
|
||||
settings = get_settings()
|
||||
if not settings.morning_digest_enabled:
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
tz_name = resolve_timezone(db)
|
||||
try:
|
||||
tz = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
tz = ZoneInfo("Europe/Moscow")
|
||||
|
||||
now = datetime.now(tz)
|
||||
target_min = settings.morning_digest_hour * 60 + settings.morning_digest_minute
|
||||
current_min = now.hour * 60 + now.minute
|
||||
if current_min < target_min or current_min >= target_min + 3:
|
||||
return
|
||||
|
||||
today = now.date().isoformat()
|
||||
if get_state(db, "last_morning_digest_date") == today:
|
||||
return
|
||||
|
||||
digest = build_morning_digest(db, include_news=True)
|
||||
post_chat_notice(digest)
|
||||
set_state(db, "last_morning_digest_date", today)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _tick_netdata() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for notice in check_netdata_alerts(db):
|
||||
post_chat_notice(notice)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _tick_rofl() -> None:
|
||||
settings = get_settings()
|
||||
if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled:
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
tz_name = resolve_timezone(db)
|
||||
try:
|
||||
tz = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
tz = ZoneInfo("Europe/Moscow")
|
||||
now = datetime.now(tz)
|
||||
last_raw = get_state(db, "last_comfy_rofl_at")
|
||||
if last_raw:
|
||||
try:
|
||||
last_at = datetime.fromisoformat(last_raw)
|
||||
if last_at.tzinfo is None:
|
||||
last_at = last_at.replace(tzinfo=tz)
|
||||
if (now - last_at).total_seconds() < settings.comfyui_rofl_min_interval_hours * 3600:
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if random.random() > settings.comfyui_rofl_probability:
|
||||
return
|
||||
|
||||
today = now.date().isoformat()
|
||||
|
||||
count_raw = get_state(db, f"comfy_rofl_count_{today}") or "0"
|
||||
try:
|
||||
count = int(count_raw)
|
||||
except ValueError:
|
||||
count = 0
|
||||
if count >= settings.comfyui_rofl_max_per_day:
|
||||
return
|
||||
|
||||
client = ComfyUIClient()
|
||||
prompt = client.random_rofl_prompt()
|
||||
result = await client.generate_image(prompt)
|
||||
if not result.get("ok"):
|
||||
logger.warning("Rofl image failed: %s", result.get("error"))
|
||||
return
|
||||
|
||||
url = result.get("url", "")
|
||||
post_chat_notice(
|
||||
f"🎨 **Рофл дня**\n\n\n\n_{prompt}_"
|
||||
)
|
||||
set_state(db, f"comfy_rofl_count_{today}", str(count + 1))
|
||||
set_state(db, "last_comfy_rofl_at", now.isoformat())
|
||||
finally:
|
||||
db.close()
|
||||
@@ -8,6 +8,7 @@ from app.api.routes import api_router
|
||||
from app.config import get_settings
|
||||
from app.db.base import init_db
|
||||
from app.fitness.watcher import fitness_watcher_loop
|
||||
from app.homelab.watcher import homelab_watcher_loop
|
||||
from app.pomodoro.watcher import pomodoro_watcher_loop
|
||||
|
||||
|
||||
@@ -16,13 +17,17 @@ async def lifespan(_: FastAPI):
|
||||
init_db()
|
||||
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
|
||||
fitness_task = asyncio.create_task(fitness_watcher_loop())
|
||||
homelab_task = asyncio.create_task(homelab_watcher_loop())
|
||||
yield
|
||||
pomodoro_task.cancel()
|
||||
fitness_task.cancel()
|
||||
homelab_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await pomodoro_task
|
||||
with suppress(asyncio.CancelledError):
|
||||
await fitness_task
|
||||
with suppress(asyncio.CancelledError):
|
||||
await homelab_task
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
|
||||
@@ -5,6 +5,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.fitness.service import FitnessService
|
||||
from app.fitness.structuring import structure_meal, structure_workout
|
||||
from app.homelab.comfyui import ComfyUIClient
|
||||
from app.homelab.digest import build_weather_briefing
|
||||
from app.homelab.openmeteo import OpenMeteoClient
|
||||
from app.integrations.openfoodfacts import OpenFoodFactsClient
|
||||
from app.integrations.wger import WgerClient
|
||||
from app.memory.service import MemoryService
|
||||
@@ -430,6 +433,61 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": (
|
||||
"ОБЯЗАТЕЛЬНО для вопросов о погоде, «что на улице», «будет ли дождь». "
|
||||
"Текущая погода и прогноз по часам."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hours_ahead": {
|
||||
"type": "integer",
|
||||
"description": "Сколько часов прогноза (по умолчанию 12)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_morning_briefing",
|
||||
"description": "Утренний брифинг: погода и заголовки новостей.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"include_news": {
|
||||
"type": "boolean",
|
||||
"description": "Включить новости (по умолчанию true)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_image",
|
||||
"description": (
|
||||
"Сгенерировать картинку через ComfyUI на домашнем GPU. "
|
||||
"Только по явному запросу или редко по рофлу."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "Описание картинки на английском"},
|
||||
"negative_prompt": {"type": "string"},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -593,6 +651,25 @@ async def execute_tool(
|
||||
minute=arguments.get("minute"),
|
||||
interval_hours=arguments.get("interval_hours"),
|
||||
)
|
||||
elif name == "get_weather":
|
||||
hours = int(arguments.get("hours_ahead") or 12)
|
||||
client = OpenMeteoClient()
|
||||
weather = client.fetch_current_and_hourly(hours_ahead=hours)
|
||||
result = {
|
||||
"weather": weather,
|
||||
"rain_summary": client.rain_summary(hours_ahead=hours) if weather.get("ok") else "",
|
||||
}
|
||||
elif name == "get_morning_briefing":
|
||||
include_news = arguments.get("include_news", True)
|
||||
result = build_weather_briefing(
|
||||
hours_ahead=12,
|
||||
include_news=bool(include_news),
|
||||
)
|
||||
elif name == "generate_image":
|
||||
result = await ComfyUIClient().generate_image(
|
||||
arguments.get("prompt", ""),
|
||||
negative_prompt=arguments.get("negative_prompt"),
|
||||
)
|
||||
else:
|
||||
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -17,3 +17,10 @@
|
||||
- «Что ты помнишь» → recall_memories или факты из контекста
|
||||
- Имя, часовой пояс → update_profile
|
||||
- Не выдумывай факты о пользователе
|
||||
|
||||
Стиль:
|
||||
- В ответах пользователю не используй эмодзи
|
||||
|
||||
Погода и дайджест:
|
||||
- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода]
|
||||
- Утренний брифинг — get_morning_briefing
|
||||
|
||||
@@ -6,3 +6,4 @@ openai>=1.55.0
|
||||
python-dotenv>=1.0.1
|
||||
aiosqlite>=0.20.0
|
||||
httpx>=0.28.0
|
||||
feedparser>=6.0.11
|
||||
|
||||
Reference in New Issue
Block a user