Asosiy kontentga o‘tish

Maqolalar 17 daqiqa 167 ko‘rish

AI tushunadi, formula hisoblaydi: LLM'ni narx hisobiga aralashtirmaslik

Mijoz yukini erkin matnda yozadi — «20 quti aksessuar, har biri 3 kg». Bot narxni aytishi kerak. LLM'ga hisoblatish — xatoga ochiq eshik. Humo Logistics botidagi pattern: AI faqat ajratadi, narxni deterministik formula beradi.

telegram-bot ai python backend

Mundarija

Humo Logistics botida mijoz yuk narxini ikki yo'l bilan so'raydi: qadam-baqadam forma orqali yoki erkin matn bilan:

«Xitoydan 20 quti telefon aksessuari keladi, har quti 3 kg, jami yarim kub»

Bot bunga aniq narx qaytarishi kerak. Birinchi ko'ringan yechim — matnni GPT'ga berib, «narxini hisoblab ber» deyish. Bu yechim production'da portlaydi, va nega portlashini tushunish LLM bilan jiddiy tizim quradigan har bir backendchi uchun majburiy saboq.

Nega LLM'ga hisoblatish mumkin emas#

Muammo Oqibat
LLM arifmetikada adashadi 3 × 20 = 60 o'rniga "taxminan 58" chiqishi mumkin — mijozga noto'g'ri narx
Natija qayta tiklanmaydi Bir xil savolga bugun boshqa, ertaga boshqa javob — audit qilib bo'lmaydi
Tariflar modelda emas Model kompaniyaning joriy narxlarini bilmaydi; prompt'ga tiqilgan tarif esa har o'zgarishda prompt-versiya muammosi
Javobgarlik "Nega bu narx?" degan savolga "AI shunday dedi" — biznes uchun javob emas

Yechim — mas'uliyatni kesib ajratish:

Sxema
Erkin matn ──► LLM (STRUKTURA AJRATADI) ──► {"name": "...", "qty": 20, ...}
                                                   │
                                                   ▼
                            Deterministik tarif xizmati (NARXNI HISOBLAYDI)
                                                   │
                                                   ▼
                                          Bir xil kirish = bir xil narx

AI'ning vazifasi — tabiiy tildan strukturali ma'lumot chiqarish (bunga u zo'r). Narx esa oddiy, testlangan, versiyalangan Python funksiyasidan chiqadi (bunga LLM umuman kerak emas).

1-qadam: chiqish sxemasi — Pydantic#

Fayl tuzilishi
bot/
├── ai/
│   ├── __init__.py
│   ├── schemas.py       ← LLM chiqishi sxemasi (hozir shu yerdamiz)
│   └── extractor.py     ← LLM chaqiruvi
├── tariff/
│   ├── __init__.py
│   └── calculator.py    ← deterministik hisob
└── handlers/
    └── ai_calc.py       ← bot handler: ikkalasini bog'laydi
Python
# bot/ai/schemas.py
from pydantic import BaseModel, Field


class CargoItem(BaseModel):
    """LLM'dan qaytishi SHART bo'lgan struktura — boshqa hech narsa."""

    name: str = Field(description="Tovar nomi, qisqa")
    quantity: int = Field(gt=0, description="Nechta birlik (quti, dona...)")
    unit_weight_kg: float | None = Field(
        default=None, ge=0, description="Bir birlik og'irligi, kg. Aytilmagan bo'lsa null"
    )
    total_volume_m3: float | None = Field(
        default=None, ge=0, description="Umumiy hajm, m³. Aytilmagan bo'lsa null"
    )


class ExtractionResult(BaseModel):
    items: list[CargoItem]
    # LLM topa olmagan narsani TAXMIN QILMASIN — ochiq aytsin:
    missing: list[str] = Field(
        default_factory=list,
        description="Hisob uchun yetishmayotgan ma'lumotlar ro'yxati (o'zbekcha)",
    )

missing maydoni — dizayndagi eng muhim qaror. LLM'ga "bilmasang null qoldir va missing'ga yoz" degan yo'lni ochamiz; aks holda u taxmin qiladi — va taxminiy og'irlikdan hisoblangan narx to'g'ridek ko'rinadigan yolg'on bo'ladi.

2-qadam: ajratuvchi — structured output bilan#

Terminal
uv add openai pydantic
Python
# bot/ai/extractor.py
from functools import cache

from openai import AsyncOpenAI

from .schemas import ExtractionResult

SYSTEM = """Sen logistika botining ajratuvchisisan. Foydalanuvchi yuk tavsifini
o'zbek, rus yoki aralash tilda yozadi. Vazifang — FAQAT strukturani ajratish.
Qoidalar:
- hech narsani hisoblama, narx aytma;
- aytilmagan qiymatni taxmin qilma — null qoldir va missing'ga yoz;
- og'irlikni kg'da, hajmni m³'da yoz: tonna→kg, gramm→kg, litr→m³ (1000 litr = 1 m³),
  "yarim kub" → 0.5."""


class ExtractionRefused(Exception):
    """Model javob berishdan bosh tortdi — sxemali natija yo'q."""


@cache
def _client() -> AsyncOpenAI:
    # Mijoz birinchi chaqiruvda yaratiladi. OPENAI_API_KEY yo'q bo'lsa, xato
    # import paytida butun botni emas, faqat shu chaqiruvni yiqitadi — handler
    # uni ushlab, mijozni formaga yo'naltiradi.
    # SDK standart holatda javobni 600 soniya kutadi va 2 marta qayta uradi —
    # chatda odam javob kutyapti, shuning uchun kutishni qisqartiramiz
    return AsyncOpenAI(timeout=20, max_retries=1)


async def extract_cargo(text: str) -> ExtractionResult:
    # async mijoz: sinxron chaqiruv so'rov davomida butun botni to'xtatib qo'yardi
    response = await _client().chat.completions.parse(
        model="gpt-4o-mini",  # ajratish uchun kichik model yetarli va arzon
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": text},
        ],
        # parse() javobni Pydantic sxemaga majburlaydi: model sxemadan
        # tashqari JSON qaytara olmaydi
        response_format=ExtractionResult,
        temperature=0,  # ajratish ijod emas — barqarorlik kerak
    )
    message = response.choices[0].message
    if message.parsed is None:
        # model rad etsa, parsed bo'sh keladi, sababi esa refusal'da
        raise ExtractionRefused(message.refusal or "bo'sh javob")
    return message.parsed

Nega temperature=0 va kichik model

Ajratish — "ijodiy" vazifa emas: bitta to'g'ri javob bor. temperature=0 barqarorlikni oshiradi, gpt-4o-mini esa bu turdagi vazifada katta modeldan deyarli farq qilmaydi, lekin gpt-4o dan ~17 baravar arzon ($0.15 va $2.50 — million kirish tokeni uchun). Katta modelni katta ishga saqlang (seriyaning 4-qismida ko'ramiz).

Kodda uchta qaror bor, har biri real muammodan saqlaydi:

  • AsyncOpenAI va await. aiogram bitta event loop'da ishlaydi. Sinxron OpenAI() chaqiruvi javob kelguncha shu loop'ni band qiladi — AI bir foydalanuvchiga 2 soniya javob yozayotganda, qolgan hamma foydalanuvchilar ham 2 soniya kutadi. Sinovda sinxron chaqiruv paytida boshqa so'rovga javob 2.01 soniya kechikdi, async bilan kechikish bo'lmadi;

  • mijoz birinchi chaqiruvda yaratiladi. OPENAI_API_KEY bo'lmasa, OpenAI() darhol xato beradi. Mijoz modul darajasida yaratilsa, bu xato handler import qilinayotganda chiqadi va bot umuman ishga tushmaydi — "AI'siz ham ishlaydi" degan va'da buziladi. timeout=20 esa SDK'ning standart 600 soniyalik kutishi o'rniga: chatda odam javob kutyapti;

  • parsed is None tekshiruvi. Structured output modelni sxemaga majburlaydi, lekin model so'rovni rad etishi mumkin — unda parsed bo'sh, sababi refusal maydonida keladi. Tekshiruvsiz handler None.missing bilan yiqilardi.

3-qadam: deterministik kalkulyator#

Bu qism LLM'dan mutlaqo bexabar — oddiy, sof funksiya. Humo'dagi kabi zichlikka asoslangan tarif: yengil-hajmli yuk kubda, og'ir-zich yuk kilo'da hisoblanadi. Pul bilan ishlagani uchun sonlar float emas, Decimal:

Python
# bot/tariff/calculator.py
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal

from bot.ai.schemas import CargoItem

CENT = Decimal("0.01")


@dataclass(frozen=True)
class Tariff:
    """Zichlik oralig'iga bitta narx: [min_density, max_density) kg/m³."""

    min_density: Decimal
    max_density: Decimal
    price_per_kg: Decimal | None  # zich yuk uchun
    price_per_m3: Decimal | None  # hajmli yuk uchun


# Real tizimda bu ro'yxat bazadan/Excel'dan keladi (Humo'da menejerlar
# o'zi yangilaydi) — bu yerda soddalik uchun konstanta.
# Pul — Decimal: float'da 34.425 ikkilik ko'rinishda 34.4249999... bo'lib,
# sent noto'g'ri tomonga yuvarlanadi
TARIFFS = [
    Tariff(Decimal("0"), Decimal("100"), None, Decimal("180.00")),  # juda hajmli: kub bilan
    Tariff(Decimal("100"), Decimal("250"), Decimal("1.10"), None),  # o'rtacha: kilo bilan
    Tariff(Decimal("250"), Decimal("Infinity"), Decimal("0.85"), None),  # zich: arzonroq
]


class CalculationError(Exception):
    pass


def calculate(items: list[CargoItem]) -> dict:
    # Sof funksiya o'z shartlarini O'ZI himoya qiladi: LLM missing'ga yozishni
    # unutsa ham, og'irligi yoki hajmi yo'q tovar jimgina 0 deb hisoblanmaydi
    incomplete = [i.name for i in items if i.unit_weight_kg is None or i.total_volume_m3 is None]
    if incomplete:
        raise CalculationError("og'irligi yoki hajmi aytilmagan: " + ", ".join(incomplete))

    # float → Decimal str() orqali: 0.1 kabi sonning ikkilik xatosi olib kirilmasin
    total_kg = sum((Decimal(str(i.unit_weight_kg)) * i.quantity for i in items), Decimal(0))
    total_m3 = sum((Decimal(str(i.total_volume_m3)) for i in items), Decimal(0))
    if total_kg <= 0 or total_m3 <= 0:
        raise CalculationError("og'irlik va hajm musbat bo'lishi shart")

    density = total_kg / total_m3
    tariff = next(t for t in TARIFFS if t.min_density <= density < t.max_density)

    if tariff.price_per_kg is not None:
        price = (total_kg * tariff.price_per_kg).quantize(CENT, rounding=ROUND_HALF_UP)
        basis = f"{total_kg.normalize():f} kg × ${tariff.price_per_kg}"
    else:
        price = (total_m3 * tariff.price_per_m3).quantize(CENT, rounding=ROUND_HALF_UP)
        basis = f"{total_m3.normalize():f} m³ × ${tariff.price_per_m3}"

    return {
        "price_usd": price,
        "density": density.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP),
        "basis": basis,
    }

E'tibor bering: natijada basis bor — "narx qayerdan chiqdi" izohi. Mijozga ham, auditga ham javob tayyor: "60 kg × $1.10". LLM'li yechimda bu savolga javob yo'q edi.

Ikki himoya "deterministik" so'zini haqiqatan to'g'ri qiladi:

  • Decimal va ROUND_HALF_UP. Formula deterministik bo'lishi yetmaydi, to'g'ri ham bo'lishi kerak. 40.5 kg × $0.85 = $34.425. float bilan round() bu narxni $34.42 qilib chiqardi, chunki 34.425 ikkilik ko'rinishda 34.4249999... bo'lib saqlanadi. Decimal esa odatiy qoidaga ko'ra $34.43 beradi. Har safar bir xil, lekin noto'g'ri tomonga yuvarlangan sent — auditda aynan shunday savollar chiqadi;

  • to'liq bo'lmagan tovar rad etiladi. Og'irligi yoki hajmi None bo'lgan tovarni or 0 bilan hisoblasak, LLM uni missing ga yozishni unutgan kuni narx jimgina arzon chiqadi: sinovda 100 dona "kabel" og'irligi aytilmagan holda hisobga 0 kg bo'lib kirdi. Shuning uchun kalkulyator LLM xabariga ishonib o'tirmaydi va o'zi tekshiradi.

4-qadam: handler — ikkalasini bog'lash#

Python
# bot/handlers/ai_calc.py
import logging

from aiogram import F, Router, html, types

from bot.ai.extractor import extract_cargo
from bot.tariff.calculator import CalculationError, calculate

router = Router()
logger = logging.getLogger(__name__)


# buyruq bo'lmagan har qanday matn — yukning erkin tavsifi
@router.message(F.text, ~F.text.startswith("/"))
async def handle_ai_calc(message: types.Message):
    try:
        extraction = await extract_cargo(message.text)
    except Exception:
        # AI ishlamasa (kalit yo'q, timeout, kvota, rad etish) — mijozni klassik
        # formaga yo'naltiramiz. Bot AI'siz ham TO'LIQ ishlashi shart
        logger.exception("AI ajratish ishlamadi")
        await message.answer(
            "Hozir avtomatik tahlil ishlamayapti. /calc bilan forma orqali hisoblang."
        )
        return

    if extraction.missing:
        # taxmin qilmaymiz — so'raymiz. AI'ning "bilmadim"i — xato emas, xususiyat
        missing = "\n".join(f"— {m}" for m in extraction.missing)
        await message.answer(f"Hisob uchun quyidagilar yetishmayapti:\n{missing}")
        return

    try:
        result = calculate(extraction.items)
    except CalculationError as exc:
        await message.answer(f"Hisoblab bo'lmadi: {exc}")
        return

    # tovar nomi foydalanuvchi matnidan chiqqan — HTML ichiga ekranlab qo'yamiz
    names = html.quote(", ".join(i.name for i in extraction.items))
    await message.answer(
        f"📦 {names}\n"
        f"Zichlik: {result['density']} kg/m³\n"
        f"Hisob: {result['basis']}\n"
        f"<b>Narx: ${result['price_usd']}</b>",
        parse_mode="HTML",
    )

Bu router har qanday oddiy matnni ushlaydi, shuning uchun uni Dispatcher'ga eng oxirida ulang: dp.include_routers(..., ai_calc.router). Aks holda u 2-qismdagi formaning javoblarini ham "erkin tavsif" deb olib ketadi. Tovar nomi esa foydalanuvchi matnidan chiqqan — parse_mode="HTML" bilan yuborishdan oldin html.quote bilan ekranlanadi: nomdagi < yoki & Telegram'da xabarni rad ettiradi.

AI yiqilganda bot yiqilmasin

OpenAI kvotasi tugashi, timeout, 500, kalit yo'qligi — bularning hammasi bo'ladi. Humo'da bunday xatoda adminlarga avtomatik ogohlantirish ketadi (3-qismda ko'ramiz), mijoz esa klassik formaga yo'naltiriladi. AI — qulaylik qatlami; asosiy yo'l usiz ham ishlashi shart.

5-qadam: test — har qatlam alohida#

Ajratishni mock qilamiz, kalkulyatorni esa to'g'ridan-to'g'ri testlaymiz — u sof funksiya, mock umuman kerak emas:

Python
# tests/test_calculator.py
from decimal import Decimal

import pytest

from bot.ai.schemas import CargoItem
from bot.tariff.calculator import CalculationError, calculate


def item(qty=20, kg=3.0, m3=0.5, name="aksessuar"):
    return CargoItem(name=name, quantity=qty, unit_weight_kg=kg, total_volume_m3=m3)


def test_medium_density_priced_per_kg():
    # 60 kg / 0.5 m³ = 120 kg/m³ → o'rta tarif, kilo bilan
    result = calculate([item()])
    assert result == {
        "price_usd": Decimal("66.00"),
        "density": Decimal("120.0"),
        "basis": "60 kg × $1.10",
    }


def test_bulky_cargo_priced_per_m3():
    # 10 kg / 0.5 m³ = 20 kg/m³ → hajmli tarif, kub bilan
    result = calculate([item(qty=10, kg=1.0)])
    assert result["basis"] == "0.5 m³ × $180.00"
    assert result["price_usd"] == Decimal("90.00")


def test_half_cent_rounds_up():
    # 40.5 kg × $0.85 = 34.425 → 34.43 (float bilan 34.42 chiqardi)
    result = calculate([item(qty=1, kg=40.5, m3=0.1)])
    assert result["price_usd"] == Decimal("34.43")


def test_item_without_weight_is_not_priced_as_zero():
    items = [item(), item(qty=100, kg=None, name="kabel")]
    with pytest.raises(CalculationError, match="kabel"):
        calculate(items)


def test_zero_weight_rejected():
    with pytest.raises(CalculationError):
        calculate([item(kg=0.0, m3=0.0)])
Terminal
uv add --dev pytest
uv run python -m pytest tests -q

Bir xil kirish har doim bir xil chiqish berishi — shu testlarning o'zi LLM'li yechimda yozib bo'lmaydigan narsa edi.

Xulosa#

  • LLM — parser, kalkulyator — formula: chegara shu yerdan o'tadi;
  • Structured output (response_format + Pydantic) modelning "erkin ijodini" sxema ichiga qamaydi;

  • missing maydoni taxminni taqiqlaydi — bilmagan narxdan noto'g'ri narx yaxshi emas, so'ralgan savol yaxshi;

  • AI — qulaylik qatlami: u yiqilganda bot klassik yo'lda ishlashda davom etadi; chaqiruv esa async va qisqa timeout bilan — bitta sekin javob butun botni kutdirmaydi;

  • Pul — Decimal: deterministik formula noto'g'ri sentni ham har safar bir xil qaytaradi;

  • Narxning izohi (basis) bo'lishi shart — auditga "AI shunday dedi" javob emas.

Keyingi qismda o'sha "klassik yo'l"ning o'zini to'g'ri quramiz: aiogram FSM — ko'p bosqichli dialog-formalar, holatlar mashinasi va "Ortga" tugmasi bilan ishlash.

17 daqiqa 167 marta o‘qildi