added api

This commit is contained in:
2026-06-10 10:29:21 +03:00
parent d0bdd1e95c
commit 73baf4dbe1
26 changed files with 1201 additions and 6 deletions
+46
View File
@@ -42,6 +42,52 @@ GITEA_WEBHOOK_SECRET=generate_a_random_secret
REPOS_DIR=/data/repos 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) # Vector DB (phase 3)
QDRANT_PORT=6333 QDRANT_PORT=6333
QDRANT_GRPC_PORT=6334 QDRANT_GRPC_PORT=6334
+49 -5
View File
@@ -175,7 +175,7 @@ data/ SQLite БД (создаётся автоматически)
| **Факты** | устойчивые знания с категорией и важностью | | **Факты** | устойчивые знания с категорией и важностью |
| **Сводка чата** | краткое содержание длинной сессии | | **Сводка чата** | краткое содержание длинной сессии |
В system prompt на каждый ответ: персонаж → **память** → помидоро → проекты. В system prompt на каждый ответ: персонаж → **время** → память → фитнес → **погода** → помидоро → проекты.
История чата обрезается до 40 последних сообщений; раннее — в `session_summaries`. История чата обрезается до 40 последних сообщений; раннее — в `session_summaries`.
**Автоизвлечение:** после каждого ответа LLM анализирует ход диалога и сохраняет **Автоизвлечение:** после каждого ответа LLM анализирует ход диалога и сохраняет
@@ -208,12 +208,56 @@ lookup wger + Open Food Facts, напоминания в чат (`💪`), вкл
Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3». Чат: «обед: гречка 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&current=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 - RAG по файлам (Qdrant)
- Фаза 5: RAG по файлам - Telegram-бот
- Проактивные чаты по расписанию - Taiga/fitness в утреннем дайджесте
- Telegram, графики веса, LLM-мотивация в напоминаниях - Графики веса, LLM-мотивация в напоминаниях
## Модель ## Модель
+3 -1
View File
@@ -1,9 +1,10 @@
from fastapi import APIRouter 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 = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"]) 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(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"]) api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
api_router.include_router(character.router, tags=["character"]) 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(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"]) api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(webhooks.router, tags=["webhooks"]) api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"])
+39
View File
@@ -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&current=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,
},
}
+21
View File
@@ -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")
+4
View File
@@ -19,6 +19,10 @@ TOOLS_INSTRUCTIONS = """
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе. - Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools. - Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
- Никогда не пиши «ожидаю ответа от системы». - Никогда не пиши «ожидаю ответа от системы».
- В текстовых ответах пользователю не используй эмодзи.
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
- Утренний брифинг (погода + новости) → get_morning_briefing.
- Картинки: generate_image (ComfyUI Anima) — промпт на английском, booru-теги + короткое описание; не злоупотребляй.
""".strip() """.strip()
DEFAULT_CARD: dict[str, Any] = { DEFAULT_CARD: dict[str, Any] = {
+6
View File
@@ -76,6 +76,8 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
"lookup_food", "lookup_food",
"lookup_exercise", "lookup_exercise",
"calc_fitness_targets", "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 "выкл" state = "вкл" if r.get("enabled") else "выкл"
return f"💪 **Напоминание {r.get('kind')}** · {state}" 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 return None
+4
View File
@@ -13,6 +13,8 @@ from app.chat.notices import (
format_tool_notice, format_tool_notice,
) )
from app.fitness.context import format_fitness_context, get_fitness_snapshot 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 ( from app.memory.context import (
format_identity_hint, format_identity_hint,
format_memory_context, format_memory_context,
@@ -64,8 +66,10 @@ class ChatService:
projects_snapshot = get_projects_snapshot(self.db) projects_snapshot = get_projects_snapshot(self.db)
return ( return (
f"{self.character.get_system_prompt()}\n\n" 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_memory_context(memory_snapshot)}\n\n"
f"{format_fitness_context(fitness_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_pomodoro_context(status)}\n\n"
f"{format_projects_context(projects_snapshot)}" f"{format_projects_context(projects_snapshot)}"
) )
+52
View File
@@ -40,6 +40,54 @@ class Settings(BaseSettings):
openfoodfacts_base_url: str = "https://world.openfoodfacts.org" openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
fitness_reminders_enabled: bool = True 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 @property
def cors_origins_list(self) -> list[str]: def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] 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: def gitea_configured(self) -> bool:
return bool(self.gitea_token) 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: def load_system_prompt(self) -> str:
path = Path(self.system_prompt_path) path = Path(self.system_prompt_path)
if path.is_file(): if path.is_file():
+10
View File
@@ -215,6 +215,16 @@ class FitnessReminder(Base):
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) 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): class WorkItem(Base):
__tablename__ = "work_items" __tablename__ = "work_items"
View File
+276
View File
@@ -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)
+42
View File
@@ -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)
+54
View File
@@ -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
+70
View File
@@ -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
+53
View File
@@ -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)}
+21
View File
@@ -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()
+142
View File
@@ -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)
+64
View File
@@ -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]
+22
View File
@@ -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()
+130
View File
@@ -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()
+5
View File
@@ -8,6 +8,7 @@ from app.api.routes import api_router
from app.config import get_settings from app.config import get_settings
from app.db.base import init_db from app.db.base import init_db
from app.fitness.watcher import fitness_watcher_loop from app.fitness.watcher import fitness_watcher_loop
from app.homelab.watcher import homelab_watcher_loop
from app.pomodoro.watcher import pomodoro_watcher_loop from app.pomodoro.watcher import pomodoro_watcher_loop
@@ -16,13 +17,17 @@ async def lifespan(_: FastAPI):
init_db() init_db()
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop()) pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
fitness_task = asyncio.create_task(fitness_watcher_loop()) fitness_task = asyncio.create_task(fitness_watcher_loop())
homelab_task = asyncio.create_task(homelab_watcher_loop())
yield yield
pomodoro_task.cancel() pomodoro_task.cancel()
fitness_task.cancel() fitness_task.cancel()
homelab_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await pomodoro_task await pomodoro_task
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await fitness_task await fitness_task
with suppress(asyncio.CancelledError):
await homelab_task
def create_app() -> FastAPI: def create_app() -> FastAPI:
+77
View File
@@ -5,6 +5,9 @@ from sqlalchemy.orm import Session
from app.fitness.service import FitnessService from app.fitness.service import FitnessService
from app.fitness.structuring import structure_meal, structure_workout 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.openfoodfacts import OpenFoodFactsClient
from app.integrations.wger import WgerClient from app.integrations.wger import WgerClient
from app.memory.service import MemoryService 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", "type": "function",
"function": { "function": {
@@ -593,6 +651,25 @@ async def execute_tool(
minute=arguments.get("minute"), minute=arguments.get("minute"),
interval_hours=arguments.get("interval_hours"), 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: else:
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
+7
View File
@@ -17,3 +17,10 @@
- «Что ты помнишь» → recall_memories или факты из контекста - «Что ты помнишь» → recall_memories или факты из контекста
- Имя, часовой пояс → update_profile - Имя, часовой пояс → update_profile
- Не выдумывай факты о пользователе - Не выдумывай факты о пользователе
Стиль:
- В ответах пользователю не используй эмодзи
Погода и дайджест:
- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода]
- Утренний брифинг — get_morning_briefing
+1
View File
@@ -6,3 +6,4 @@ openai>=1.55.0
python-dotenv>=1.0.1 python-dotenv>=1.0.1
aiosqlite>=0.20.0 aiosqlite>=0.20.0
httpx>=0.28.0 httpx>=0.28.0
feedparser>=6.0.11
+3
View File
@@ -17,6 +17,9 @@ function noticeLabel(content: string): string {
if (content.startsWith("🔀")) return "git"; 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 "погода";
if (content.startsWith("🎨")) return "картинка";
if (content.startsWith("⚠️")) return "сервер";
return "система"; return "система";
} }