Asosiy kontentga o‘tish

Maqolalar

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

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

#python #ai #llm #openai #telegram-bot #arxitektura

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:

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#

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
# 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#

# bot/ai/extractor.py
from openai import OpenAI

from .schemas import ExtractionResult

client = OpenAI()  # OPENAI_API_KEY muhitdan olinadi

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;
- birliklarni normalizatsiya qil: tonna→kg, litr→m³ emas (hajm faqat m³/kub)."""


def extract_cargo(text: str) -> ExtractionResult:
    # parse() javobni to'g'ridan-to'g'ri Pydantic sxemaga majburlaydi:
    # model sxemadan tashqari JSON qaytara olmaydi
    response = client.chat.completions.parse(
        model="gpt-4o-mini",  # ajratish uchun kichik model yetarli va arzon
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": text},
        ],
        response_format=ExtractionResult,
        temperature=0,  # ajratish ijod emas — barqarorlik kerak
    )
    return response.choices[0].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 ~20 baravar arzon. Katta modelni katta ishga saqlang (seriyaning 4-qismida ko'ramiz).

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:

# bot/tariff/calculator.py
from dataclasses import dataclass

from bot.ai.schemas import CargoItem


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

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


# Real tizimda bu ro'yxat bazadan/Excel'dan keladi (Humo'da menejerlar
# o'zi yangilaydi) — bu yerda soddalik uchun konstanta
TARIFFS = [
    Tariff(0, 100, None, 180.0),  # juda hajmli: kub bilan
    Tariff(100, 250, 1.10, None),  # o'rtacha: kilo bilan
    Tariff(250, float("inf"), 0.85, None),  # zich: kilo bilan, arzonroq
]


class CalculationError(Exception):
    pass


def calculate(items: list[CargoItem]) -> dict:
    total_kg = sum((i.unit_weight_kg or 0) * i.quantity for i in items)
    total_m3 = sum(i.total_volume_m3 or 0 for i in items)
    if total_kg <= 0 or total_m3 <= 0:
        # bu holat handler'gacha yetmasligi kerak (missing tekshiruvi bor),
        # lekin sof funksiya o'z shartlarini O'ZI himoya qiladi
        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 = round(total_kg * tariff.price_per_kg, 2)
        basis = f"{total_kg:.0f} kg × ${tariff.price_per_kg}"
    else:
        price = round(total_m3 * tariff.price_per_m3, 2)
        basis = f"{total_m3:.2f} m³ × ${tariff.price_per_m3}"

    return {"price_usd": price, "density": round(density, 1), "basis": basis}

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

4-qadam: handler — ikkalasini bog'lash#

# bot/handlers/ai_calc.py
from aiogram import types

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


async def handle_ai_calc(message: types.Message):
    try:
        extraction = extract_cargo(message.text)
    except Exception:
        # AI ishlamasa — mijozni klassik formaga yo'naltiramiz.
        # Bot AI'siz ham TO'LIQ ishlashi shart (graceful degradation)
        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

    names = ", ".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",
    )

AI yiqilganda bot yiqilmasin

OpenAI kvotasi tugashi, timeout, 500 — 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:

# tests/test_calculator.py
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):
    return CargoItem(name="aksessuar", quantity=qty, unit_weight_kg=kg, total_volume_m3=m3)


def test_dense_cargo_priced_per_kg():
    # 60 kg / 0.5 m³ = 120 kg/m³ → o'rta tarif, kilo bilan
    result = calculate([item()])
    assert result == {"price_usd": 66.0, "density": 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.50 m³ × $180.0"


def test_zero_weight_rejected():
    with pytest.raises(CalculationError):
        calculate([item(kg=0.0, m3=0.0)])

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;
  • 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.