feat(ev): proaktivní notifikace 'píchni auto' (default-off)
job ev_presence_notify + fn_ev_presence_nudge_due (SQL-first rozhodnutí+dedup); asset_vehicle.presence_nudge_enabled default false=inertní (V110). Worktree agent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
112
backend/services/ev_presence_notify.py
Normal file
112
backend/services/ev_presence_notify.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Proaktivní notifikace "auto doma + nepíchnuté + levné/přebytek → píchni ho".
|
||||
|
||||
Tenký orchestrátor: veškerá doménová logika (kdo je doma, odpojený, výhodná cena,
|
||||
SoC pod cílem) i dedup jsou v ems.fn_ev_presence_nudge_due(). Python jen zavolá
|
||||
funkci pro každou aktivní lokalitu a pro každý vrácený (= nově due, ještě
|
||||
neposlaný) řádek pošle jeden Discord nudge.
|
||||
|
||||
Dedup je čistě v DB: funkce zapíše řádek do ems.ev_presence_nudge_sent
|
||||
(on conflict do nothing) a vrátí jen ty, kterým insert skutečně prošel — tedy
|
||||
jeden nudge na "epizodu" auta doma+odpojeno. Opakované 20–30min ticky proto
|
||||
nespamují, dokud se auto nepíchne nebo neodjede (čímž se klíč epizody změní).
|
||||
|
||||
DEFAULT-OFF: funkce nevrátí nic, dokud není na vozidle
|
||||
asset_vehicle.presence_nudge_enabled = true. Job tedy běží inertně.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from app.db_json import fetch_json
|
||||
from services.notification_service import send_discord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fmt_price(value: Any) -> str:
|
||||
try:
|
||||
return f"{float(value):.2f}"
|
||||
except (TypeError, ValueError):
|
||||
return "?"
|
||||
|
||||
|
||||
def _build_message(row: asyncpg.Record) -> str:
|
||||
name = row["vehicle_name"] or "EV"
|
||||
reason = str(row["trigger_reason"] or "")
|
||||
sell = row["effective_sell_price_czk_kwh"]
|
||||
buy = row["effective_buy_price_czk_kwh"]
|
||||
soc = row["battery_level_pct"]
|
||||
tgt = row["target_soc_pct"]
|
||||
|
||||
if reason == "NEG_OR_ZERO_SELL":
|
||||
why = f"výkup je teď {_fmt_price(sell)} Kč/kWh (≤ 0) — přebytek se hodí do auta"
|
||||
else:
|
||||
why = f"nákup je teď levný: {_fmt_price(buy)} Kč/kWh"
|
||||
|
||||
soc_line = ""
|
||||
if soc is not None:
|
||||
soc_line = f"\nBaterie auta: **{_fmt_price(soc)} %**" + (
|
||||
f" (cíl {_fmt_price(tgt)} %)" if tgt is not None else ""
|
||||
)
|
||||
|
||||
return (
|
||||
f"🚗 **{name} je doma a nepíchnuté** — {why}.{soc_line}\n"
|
||||
f"Píchni ho a plán se o zbytek postará (přebytky / levné sloty)."
|
||||
)
|
||||
|
||||
|
||||
async def run_ev_presence_nudge_for_site(
|
||||
site_id: int, conn: asyncpg.Connection
|
||||
) -> int:
|
||||
"""Jedna lokalita: zavolá fn (dedup v DB) a pošle Discord pro každé due vozidlo.
|
||||
|
||||
Vrátí počet odeslaných notifikací.
|
||||
"""
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"select * from ems.fn_ev_presence_nudge_due($1::int)",
|
||||
site_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"ev_presence_nudge: fn_ev_presence_nudge_due failed site=%s", site_id
|
||||
)
|
||||
return 0
|
||||
|
||||
sent = 0
|
||||
for row in rows:
|
||||
try:
|
||||
await send_discord(conn, site_id, _build_message(row), level="info")
|
||||
sent += 1
|
||||
logger.info(
|
||||
"ev_presence_nudge sent site=%s vehicle=%s reason=%s",
|
||||
site_id,
|
||||
row["vehicle_id"],
|
||||
row["trigger_reason"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"ev_presence_nudge: Discord send failed site=%s vehicle=%s",
|
||||
site_id,
|
||||
row["vehicle_id"],
|
||||
)
|
||||
return sent
|
||||
|
||||
|
||||
async def run_ev_presence_nudge_for_all_active_sites(pool: asyncpg.Pool) -> None:
|
||||
"""Scheduler entrypoint: projde aktivní lokality a pošle proaktivní nudge."""
|
||||
async with pool.acquire() as conn:
|
||||
raw = await fetch_json(conn, "select ems.fn_vw_site_directory_active()")
|
||||
sites = raw if isinstance(raw, list) else []
|
||||
for site in sites:
|
||||
if not isinstance(site, dict) or site.get("id") is None:
|
||||
continue
|
||||
site_id = int(site["id"])
|
||||
try:
|
||||
await run_ev_presence_nudge_for_site(site_id, conn)
|
||||
except Exception:
|
||||
logger.exception("ev_presence_nudge site=%s failed", site_id)
|
||||
Reference in New Issue
Block a user