Asosiy kontentga o‘tish

Maqolalar

Maqola · 2026-08-16 · ~12 daqiqa o‘qiladi · o'rta

Botda rejalashtirilgan vazifalar: haftalik hisobot va anomaliya-alerting

#python #aiogram #telegram-bot #scheduler #monitoring

Mundarija

Avvalgi qismlarda bot so'ralganda javob berdi. Endi teskari yo'nalish: Humo botida adminlar dushanba ertalab haftalik tahlilni, oy boshida oylik hisobotni va kun davomida g'ayrioddiy o'zgarish bo'lsa — darhol ogohlantirishni so'ramasdan oladi. Buning tagida ikkita kichik, lekin puxta o'ylangan mexanizm bor: restart'ga chidamli rejalashtiruvchi va oddiy (AI'siz!) anomaliya mantiqiy.

Qaysi asbob: cron, systemd timer yoki bot ichidagi loop?#

Variant Qachon mos
cron / systemd timer Alohida skript sifatida ishlaydigan vazifa (backup, tozalash) — DevOps seriyasida ko'ramiz
Celery beat Navbat allaqachon bor bo'lgan katta tizim — Celery haqida
Bot ichidagi asyncio loop Bot doim ishlab turgan jarayon; vazifa bot konteksti (bot obyekti, adminlar ro'yxati) bilan ishlashi kerak

Bot allaqachon 24/7 jarayon — unga yana bitta asyncio vazifa qo'shish eng arzon va eng sodda yo'l. Lekin ikkita tuzoqni hal qilish shart: restart (bot har deploy'da qayta ko'tariladi — hisobot ikki marta ketmasin yoki umuman o'tib ketmasin) va xato (bitta yiqilgan vazifa butun scheduler'ni o'ldirmasin).

1-qadam: davr kalitlari — restart'ga chidamlilikning siri#

Yechim g'oyasi: har vazifa o'z davri uchun bitta kalit oladi — haftalik hisobot uchun "2026-W33", oylik uchun "2026-07", kunlik tekshiruv uchun "2026-08-16". Yuborilgan kalitlar faylda saqlanadi. Restart bo'ldimi, o'chib yondimi — kalit faylda bor ekan, vazifa qayta yuborilmaydi; fayl yo'q bo'lsa — keyingi tekshiruvda yuboriladi. Cron'dagi "o'sha daqiqada o'chiq bo'lsang, o'tib ketdi" muammosi ham yo'qoladi.

bot/
├── scheduler/
│   ├── __init__.py
│   ├── periods.py       ← davr hisob-kitobi (sof funksiyalar — testlanadi)
│   ├── state.py         ← yuborilgan kalitlar fayli
│   └── runner.py        ← asyncio loop
├── reports/
│   ├── weekly.py        ← hisobot matnini tuzish
│   └── anomaly.py       ← anomaliya mantiqiy
└── app.py
# bot/scheduler/periods.py
from datetime import datetime, timedelta, timezone

# Toshkent: UTC+5, DST yo'q — shuning uchun oddiy timedelta bilan xavfsiz
TZ = timezone(timedelta(hours=5))

WEEKLY_HOUR = 9  # dushanba 09:00
MONTHLY_DAY = 1  # oyning 1-kuni
ANOMALY_HOUR = 20  # har kuni 20:00 da kunlik tekshiruv


def week_key(now: datetime) -> str:
    """O'tgan TO'LIQ hafta kaliti: '2026-W33'."""
    last_sunday = now - timedelta(days=now.isoweekday())
    year, week, _ = last_sunday.isocalendar()
    return f"{year}-W{week:02d}"


def month_key(now: datetime) -> str:
    """O'tgan oy kaliti: '2026-07'."""
    first_of_month = now.replace(day=1)
    prev = first_of_month - timedelta(days=1)
    return f"{prev.year}-{prev.month:02d}"


def due_task(now: datetime, sent: set[str]) -> tuple[str, str] | None:
    """Navbatdagi BITTA vazifa: (tur, davr_kaliti) yoki None.

    Bitta chaqiruvda bitta vazifa — ikkitasi to'planib qolgan bo'lsa
    (masalan bot bir hafta o'chiq turdi), navbatma-navbat yuboriladi,
    adminlar oqimi bosilmaydi.
    """
    if now.isoweekday() == 1 and now.hour >= WEEKLY_HOUR:
        key = f"weekly:{week_key(now)}"
        if key not in sent:
            return ("weekly", key)
    if now.day == MONTHLY_DAY and now.hour >= WEEKLY_HOUR:
        key = f"monthly:{month_key(now)}"
        if key not in sent:
            return ("monthly", key)
    if now.hour >= ANOMALY_HOUR:
        key = f"anomaly:{now.date().isoformat()}"
        if key not in sent:
            return ("anomaly", key)
    return None

Nega >= — aynan soatga tenglik emas

now.hour == 9 sharti bot 09:00 da bir daqiqa o'chiq bo'lsa vazifani yo'qotadi. >= bilan esa "kechiksa ham yuboradi": 09:00 da o'chiq edi — 11:30 da ko'tarildi — hisobot 11:30 da ketadi. Davr kaliti takrorlanishdan saqlaydi, >= o'tkazib yuborishdan.

2-qadam: holat fayli#

# bot/scheduler/state.py
import json
from pathlib import Path

STATE_FILE = Path("var/scheduler_state.json")


def load_sent() -> set[str]:
    if not STATE_FILE.exists():
        return set()
    return set(json.loads(STATE_FILE.read_text()))


def mark_sent(key: str) -> None:
    sent = load_sent()
    sent.add(key)
    STATE_FILE.parent.mkdir(exist_ok=True)
    # oxirgi 200 kalit yetarli — fayl cheksiz o'smasin
    STATE_FILE.write_text(json.dumps(sorted(sent)[-200:], ensure_ascii=False))

3-qadam: anomaliya — AI emas, arifmetika#

"Anomaliya" so'zi qo'rqinchli eshitiladi, lekin ko'p hollarda oddiy taqqoslash yetadi: bugungi ko'rsatkich o'tgan ikki haftaning shu kunlaridagi o'rtachasidan keskin farq qilyaptimi?

# bot/reports/anomaly.py
from dataclasses import dataclass

THRESHOLD = 0.5  # ±50% dan katta og'ish — e'tiborga loyiq


@dataclass(frozen=True)
class Anomaly:
    metric: str
    today: float
    baseline: float

    @property
    def change_pct(self) -> int:
        return round((self.today - self.baseline) / self.baseline * 100)


def detect(metrics: dict[str, float], baselines: dict[str, float]) -> list[Anomaly]:
    """metrics: bugungi qiymatlar; baselines: o'tgan davr o'rtachasi."""
    found = []
    for name, today in metrics.items():
        base = baselines.get(name)
        if not base:  # tarix yo'q — taqqoslab bo'lmaydi
            continue
        if abs(today - base) / base > THRESHOLD:
            found.append(Anomaly(name, today, base))
    return found

Bu 15 qator kod Humo'da "bugun so'rovlar soni odatdagidan 70% kam — API'da muammo yo'qmi?" degan xabarni beradi. AI bu yerda shart emas — chegara aniq, mantiq tekshiriladigan. (AI'ning o'rni — topilgan anomaliyaga izoh yozish; buni keyingi qismda ko'ramiz.)

Avval deterministik, keyin aqlli

Alerting'da eng muhimi — ishonch: "bot jim = hammasi joyida" ishonchi. Deterministik mantiqni tekshirish oson, LLM'nikini — yo'q. Shuning uchun signal chiqarishni formulaga, signalni chiroyli tushuntirishni esa AI'ga bering — 1-qismdagi ajratish printsipi bu yerda ham ishlaydi.

4-qadam: runner — o'lmaydigan loop#

# bot/scheduler/runner.py
import asyncio
import logging
from datetime import datetime

from bot.reports import anomaly, weekly
from bot.scheduler.periods import TZ, due_task
from bot.scheduler.state import load_sent, mark_sent

log = logging.getLogger(__name__)

CHECK_EVERY = 60  # soniya

TASKS = {
    "weekly": weekly.send_weekly_report,
    "monthly": weekly.send_monthly_report,
    "anomaly": anomaly.run_daily_check,
}


async def scheduler_loop(bot):
    while True:
        try:
            now = datetime.now(TZ)
            task = due_task(now, load_sent())
            if task is not None:
                kind, key = task
                await TASKS[kind](bot)  # vazifaning o'zi
                mark_sent(key)  # MUVAFFAQIYATDAN KEYIN belgilanadi
                log.info("scheduler: %s yuborildi", key)
        except Exception:
            # bitta xato loop'ni o'ldirmasin: log yozamiz, keyingi aylanishda
            # qayta urinamiz (kalit belgilanmagan — vazifa yo'qolmaydi)
            log.exception("scheduler xatosi")
        await asyncio.sleep(CHECK_EVERY)
# bot/app.py (ishga tushirish)
async def main():
    # scheduler bot polling bilan YONMA-YON yashaydi
    asyncio.create_task(scheduler_loop(bot))
    await dp.start_polling(bot)

Ikki nozik qaror izohlansin:

  • mark_sent(key) vazifa muvaffaqiyatli o'tgandan keyin chaqiriladi: yuborish yiqilsa, kalit yozilmaydi va keyingi daqiqada qayta uriniladi. Teskari tartibda hisobot "yuborildi deb hisoblanib", aslida yo'qolardi;
  • try/except butun aylanani o'raydi: scheduler — uzun umrli jarayon, undagi istisno "bir martalik xato" emas, "bugungi hisobotlar o'ldi" degani.

5-qadam: test — vaqtni qo'lda berish#

due_task sof funksiya — datetime'ni qo'lda berib, har stsenariyni tekshiramiz:

# tests/test_periods.py
from datetime import datetime

from bot.scheduler.periods import TZ, due_task


def dt(day, hour):
    return datetime(2026, 8, day, hour, 0, tzinfo=TZ)  # 2026-08-17 — dushanba


def test_weekly_fires_monday_after_nine():
    assert due_task(dt(17, 9), set()) == ("weekly", "weekly:2026-W33")


def test_weekly_not_resent_same_week():
    sent = {"weekly:2026-W33"}
    # dushanba kuni hisobot ketgan; endi navbat kunlik tekshiruvda (20:00 dan keyin)
    assert due_task(dt(17, 21), sent) == ("anomaly", "anomaly:2026-08-17")


def test_quiet_before_hours():
    assert due_task(dt(18, 8), {"weekly:2026-W33"}) is None


def test_missed_monday_still_fires_tuesday():
    # bot dushanba butun kun o'chiq edi — seshanba ertalab baribir hafta kaliti yo'q...
    # lekin due_task faqat dushanba tekshiradi: bu qat'iy qaror — hafta hisobot
    # 24 soatdan ortiq kechiksa, keyingi haftani kutgan ma'qul (eskirgan tahlil
    # foydali emas). O'zingizga moslang: shartga `<= 2` kun qo'shsangiz ham bo'ladi
    assert due_task(dt(18, 9), set()) is None

Xulosa#

  • Bot ichidagi asyncio loop — doim ishlab turgan jarayon uchun eng sodda scheduler; cron va systemd timer alohida skriptlar dunyosi;
  • Restart'ga chidamlilik — davr kalitlari bilan: weekly:2026-W33 faylda bor ekan, hisobot takrorlanmaydi; yo'q ekan — kechikib bo'lsa ham yuboriladi;
  • mark_sent faqat muvaffaqiyatdan keyin — xato vazifani yo'qotmaydi;
  • Anomaliya uchun AI kerak emas — o'rtachadan og'ish formulasi yetadi, u tekshiriladi va unga ishoniladi;
  • Sof due_task funksiyasi — scheduler'ning butun aqli, va u to'liq testlanadi.

Seriya yakunida eng qizig'i: AI agent'ni botga integratsiya qilish — adminlar bazaga erkin savol beradi, agent SQL bilan javob topadi, suhbat tarixi va zaxira model bilan.