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.
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#
uv add openai pydantic
# 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:
-
AsyncOpenAIvaawait. aiogram bitta event loop'da ishlaydi. SinxronOpenAI()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_KEYbo'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=20esa SDK'ning standart 600 soniyalik kutishi o'rniga: chatda odam javob kutyapti; -
parsed is Nonetekshiruvi. Structured output modelni sxemaga majburlaydi, lekin model so'rovni rad etishi mumkin — undaparsedbo'sh, sababirefusalmaydonida keladi. Tekshiruvsiz handlerNone.missingbilan 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:
# 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:
-
DecimalvaROUND_HALF_UP. Formula deterministik bo'lishi yetmaydi, to'g'ri ham bo'lishi kerak. 40.5 kg × $0.85 = $34.425.floatbilanround()bu narxni $34.42 qilib chiqardi, chunki 34.425 ikkilik ko'rinishda 34.4249999... bo'lib saqlanadi.Decimalesa 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
Nonebo'lgan tovarnior 0bilan hisoblasak, LLM unimissingga 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#
# 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:
# 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)])
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; -
missingmaydoni 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.