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>
113 lines
3.8 KiB
Python
113 lines
3.8 KiB
Python
"""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)
|