diff --git a/.env.example b/.env.example index babc413..36551ce 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,52 @@ GITEA_WEBHOOK_SECRET=generate_a_random_secret REPOS_DIR=/data/repos +# Homelab — GPU PC 192.168.1.109 +OPENMETEO_BASE_URL=http://192.168.1.109:8085 +WEATHER_LAT=59.9343 +WEATHER_LON=30.3351 +WEATHER_LOCATION_NAME=Санкт-Петербург +WEATHER_CACHE_SEC=300 + +# News RSS (comma-separated) +NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss +NEWS_CACHE_SEC=1800 +NEWS_MAX_ITEMS=7 + +# Morning digest (Europe/Moscow or user profile timezone) +MORNING_DIGEST_ENABLED=true +MORNING_DIGEST_HOUR=8 +MORNING_DIGEST_MINUTE=0 + +# ComfyUI on GPU PC (Anima split-model — как в aiChatBot) +COMFYUI_BASE_URL=http://192.168.1.109:8188 +COMFYUI_ENABLED=true +# Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET. +COMFYUI_CHECKPOINT= +COMFYUI_UNET=anima-preview3-base.safetensors +COMFYUI_CLIP=qwen_3_06b_base.safetensors +COMFYUI_VAE=qwen_image_vae.safetensors +COMFYUI_STYLE_LORA=anima-preview-3-masterpieces-v5.safetensors +COMFYUI_STYLE_LORA_WEIGHT=0.7 +COMFYUI_STEPS=30 +COMFYUI_CFG=4 +COMFYUI_SAMPLER=er_sde +COMFYUI_SCHEDULER=simple +COMFYUI_WIDTH=1024 +COMFYUI_HEIGHT=720 +COMFYUI_NEGATIVE_PROMPT=worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia +COMFYUI_ROFL_ENABLED=true +COMFYUI_ROFL_MAX_PER_DAY=1 +COMFYUI_ROFL_PROBABILITY=0.15 +COMFYUI_ROFL_MIN_INTERVAL_HOURS=12 +GENERATED_MEDIA_DIR=./data/generated + +# Netdata on server +NETDATA_BASE_URL=http://host.docker.internal:19999 +NETDATA_PUBLIC_URL= +NETDATA_ALERTS_ENABLED=true +NETDATA_POLL_INTERVAL_SEC=120 + # Vector DB (phase 3) QDRANT_PORT=6333 QDRANT_GRPC_PORT=6334 diff --git a/README.md b/README.md index 2b4d14a..328c647 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ data/ SQLite БД (создаётся автоматически) | **Факты** | устойчивые знания с категорией и важностью | | **Сводка чата** | краткое содержание длинной сессии | -В system prompt на каждый ответ: персонаж → **память** → помидоро → проекты. +В system prompt на каждый ответ: персонаж → **время** → память → фитнес → **погода** → помидоро → проекты. История чата обрезается до 40 последних сообщений; раннее — в `session_summaries`. **Автоизвлечение:** после каждого ответа LLM анализирует ход диалога и сохраняет @@ -208,12 +208,56 @@ lookup wger + Open Food Facts, напоминания в чат (`💪`), вкл Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3». +## Homelab API (фаза 4) + +Интеграции с домашней инфраструктурой: + +| Сервис | URL по умолчанию | Назначение | +|--------|------------------|------------| +| Open-Meteo | `http://192.168.1.109:8085` | Погода СПб в контексте и tool `get_weather` | +| ComfyUI | `http://192.168.1.109:8188` | `generate_image`, редкий «рофл» в чат | +| Netdata | `http://host.docker.internal:19999` | Алерты warning/critical → notice в чат | + +**Утренний дайджест** (`MORNING_DIGEST_HOUR=8`): погода + RSS (Habr, r/programming по умолчанию). +По запросу: «что на улице», «будет ли дождь» → `get_weather`; полный брифинг → `get_morning_briefing`. + +Переменные — в `.env.example` (секция Homelab). + +### Проверка доступности + +В образе backend нет `curl`/`wget`. Удобнее всего — API-диагностика (из контейнера или с хоста): + +```bash +curl -s http://localhost:${BACKEND_PORT:-8202}/api/v1/homelab/status | python3 -m json.tool +``` + +Или изнутри backend через Python: + +```bash +docker compose exec backend python -c " +import httpx +for url in [ + 'http://192.168.1.109:8085/v1/forecast?latitude=59.93&longitude=30.33¤t=temperature_2m', + 'http://192.168.1.109:8188/system_stats', + 'http://host.docker.internal:19999/api/v1/info', +]: + try: + r = httpx.get(url, timeout=10) + print(url, '->', r.status_code, r.text[:120]) + except Exception as e: + print(url, '-> ERROR', e) +" +``` + +По умолчанию **Anima** (как в aiChatBot): `COMFYUI_UNET` + `COMFYUI_CLIP` + `COMFYUI_VAE` + style LoRA. +`COMFYUI_CHECKPOINT` оставь пустым. Для SD1.5/Pony — укажи checkpoint и очисти `COMFYUI_UNET`. + ## Следующие фазы -- Фаза 4: инструменты с обращением к внешним API -- Фаза 5: RAG по файлам -- Проактивные чаты по расписанию -- Telegram, графики веса, LLM-мотивация в напоминаниях +- RAG по файлам (Qdrant) +- Telegram-бот +- Taiga/fitness в утреннем дайджесте +- Графики веса, LLM-мотивация в напоминаниях ## Модель diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 645121f..adb3d33 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -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"]) diff --git a/backend/app/api/routes/homelab.py b/backend/app/api/routes/homelab.py new file mode 100644 index 0000000..352020e --- /dev/null +++ b/backend/app/api/routes/homelab.py @@ -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, + }, + } diff --git a/backend/app/api/routes/media.py b/backend/app/api/routes/media.py new file mode 100644 index 0000000..ad1237a --- /dev/null +++ b/backend/app/api/routes/media.py @@ -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") diff --git a/backend/app/character/card.py b/backend/app/character/card.py index be3a9a1..34c463d 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -19,6 +19,10 @@ TOOLS_INSTRUCTIONS = """ - Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе. - Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools. - Никогда не пиши «ожидаю ответа от системы». +- В текстовых ответах пользователю не используй эмодзи. +- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай. +- Утренний брифинг (погода + новости) → get_morning_briefing. +- Картинки: generate_image (ComfyUI Anima) — промпт на английском, booru-теги + короткое описание; не злоупотребляй. """.strip() DEFAULT_CARD: dict[str, Any] = { diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py index 81e5f9a..b5313fa 100644 --- a/backend/app/chat/notices.py +++ b/backend/app/chat/notices.py @@ -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![image]({url})" + return None diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index d618d43..2507928 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -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)}" ) diff --git a/backend/app/config.py b/backend/app/config.py index e7fe4f0..9dcb613 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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(): diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 61e17bc..d323ab2 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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" diff --git a/backend/app/homelab/__init__.py b/backend/app/homelab/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/homelab/comfyui.py b/backend/app/homelab/comfyui.py new file mode 100644 index 0000000..04723c7 --- /dev/null +++ b/backend/app/homelab/comfyui.py @@ -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) diff --git a/backend/app/homelab/context.py b/backend/app/homelab/context.py new file mode 100644 index 0000000..630b4ca --- /dev/null +++ b/backend/app/homelab/context.py @@ -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) diff --git a/backend/app/homelab/digest.py b/backend/app/homelab/digest.py new file mode 100644 index 0000000..69f618f --- /dev/null +++ b/backend/app/homelab/digest.py @@ -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 diff --git a/backend/app/homelab/monitoring.py b/backend/app/homelab/monitoring.py new file mode 100644 index 0000000..a6ffe3a --- /dev/null +++ b/backend/app/homelab/monitoring.py @@ -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 diff --git a/backend/app/homelab/netdata.py b/backend/app/homelab/netdata.py new file mode 100644 index 0000000..86efaf4 --- /dev/null +++ b/backend/app/homelab/netdata.py @@ -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)} diff --git a/backend/app/homelab/notices.py b/backend/app/homelab/notices.py new file mode 100644 index 0000000..5f0a494 --- /dev/null +++ b/backend/app/homelab/notices.py @@ -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() diff --git a/backend/app/homelab/openmeteo.py b/backend/app/homelab/openmeteo.py new file mode 100644 index 0000000..22d0370 --- /dev/null +++ b/backend/app/homelab/openmeteo.py @@ -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) diff --git a/backend/app/homelab/rss.py b/backend/app/homelab/rss.py new file mode 100644 index 0000000..e9e34d2 --- /dev/null +++ b/backend/app/homelab/rss.py @@ -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] diff --git a/backend/app/homelab/state.py b/backend/app/homelab/state.py new file mode 100644 index 0000000..866fc46 --- /dev/null +++ b/backend/app/homelab/state.py @@ -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() diff --git a/backend/app/homelab/watcher.py b/backend/app/homelab/watcher.py new file mode 100644 index 0000000..fba71cc --- /dev/null +++ b/backend/app/homelab/watcher.py @@ -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![rofl]({url})\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() diff --git a/backend/app/main.py b/backend/app/main.py index 54ff56a..41311d8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 5f01fab..de30d2d 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -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) diff --git a/backend/prompts/assistant.md b/backend/prompts/assistant.md index e86008b..33fa099 100644 --- a/backend/prompts/assistant.md +++ b/backend/prompts/assistant.md @@ -17,3 +17,10 @@ - «Что ты помнишь» → recall_memories или факты из контекста - Имя, часовой пояс → update_profile - Не выдумывай факты о пользователе + +Стиль: +- В ответах пользователю не используй эмодзи + +Погода и дайджест: +- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода] +- Утренний брифинг — get_morning_briefing diff --git a/backend/requirements.txt b/backend/requirements.txt index d73d6fc..8bfd868 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index d3a99a1..43d7333 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -17,6 +17,9 @@ function noticeLabel(content: string): string { if (content.startsWith("🔀")) return "git"; if (content.startsWith("🧠")) return "память"; if (content.startsWith("💪")) return "фитнес"; + if (content.startsWith("🌤")) return "погода"; + if (content.startsWith("🎨")) return "картинка"; + if (content.startsWith("⚠️")) return "сервер"; return "система"; }