ОТЧЕТ Вход за редакция
Проект 1

Гласов асистент

Телефонен агент на български за AIKO XXXL. Седем платформи минаха през ръцете ми, две останаха в работа. Тук е всичко - какво построих, какво се чупеше, какво поправих и какво още не е готово.

Емилиан
обновен 13 октомври 2026
Parloa · Vapi · Dograh · PolyAI

Накратко

състояние към 13 октомври 2026

Задачата е една и не се е променяла - клиентът звъни на телефона на AIKO, казва си въпроса на български и получава отговор, без да чака оператор. Отзад стои AMK - там са поръчките, документите, магазините, свободните дати за доставка. Агентът трябва да чете от AMK, а не да си измисля.

Оказа се, че трудното не е моделът. Модел, който говори български, се включва навсякъде. Трудното е три други неща - разпознаването на речта, гласът и дали платформата може сама да си подновява OAuth2 токена. Токенът на AMK живее 300 секунди. Платформа, която не може да го поднови, отпада още на първия разговор.

Минах през седем платформи. Parloa беше първата и там се построи цялата логика. Vapi пое продукцията. Dograh го пробвах, защото е с отворен код и щеше да е безплатно. Локален модел с LoRA пробвах, защото щеше да е още по-евтино. PolyAI го пренесох последен и в момента работи на EU сървърите им. Azure, Genesys, Copilot Studio, Synthflow, Amazon и Cognigy отпаднаха на хартия, преди да стигна до код.

7 платформи разгледани
4 платформи с реален код
21 028 реални обаждания изчетени
12 справки към AMK
42/42 тестове на живо, PolyAI EU
300 s живот на токена
Езиковият модел не е пречка при никоя платформа. Пречката, когато я има, е в разпознаването на речта, в синтеза на гласа и в подновяването на токена.

Parloa - там се роди логиката

от версия 105 до версия 146

Parloa е първото, което построих докрай. Девет подагента, три умения, десет справки към AMK и персона от четиринайсет раздела. Минах през четирийсет и една версии на конфигурацията. Версия 105 влезе на живо непроверена - проверката ми беше прекъсната от самия deploy, и това си го записах като грешка, не като случайност.

Силата на Parloa са Code Steps - парчета JavaScript, които се въртят след HTTP заявката. Там сложих цялата сметка за плащанията и цялата аритметика за бройките. Това е детерминистично, не зависи от настроението на модела и до ден днешен е най-надеждната част от целия проект. Когато по-късно пренесох всичко във Vapi, тази сметка стана проза в промпт от дванайсет хиляди знака. Загубата е реална.

Какво точно построих

  • Девет подагента - Entry Point, Поръчки и наличност, Магазини и обща информация, Доставка разнос и монтаж, Плащане и финанси, Връщане и рекламации, Специални услуги, Транспортни зони и свободни слотове, Fallback.
  • Десет справки в едно умение, всяка от които е верига - първо get_token, после самата заявка с Bearer, после Code Step.
  • Два Code Step-а - format_order_status за поръчките и format_document_status за документите. Вторият стигна до единайсет изходни параметъра.
  • Прехвърляне към оператор по SIP към нашия Asterisk. Една дестинация, не повече.
  • Резервно копие от 147 613 знака, свалено на 31 август 2026.
1

Редакциите ми не правеха абсолютно нищо

поправено
Какво ставаше
Писах инструкции по цял час и агентът се държеше точно както преди. Нито една промяна не се усещаше.
Защо
Пишех всичко в полето Activation. Работещото поле е Resolution. Activation казва само КОГА агентът да се включи - сложиш ли там нещо друго, то безшумно се бие с Resolution.
Какво направих
Преместих всичко в Resolution и оставих в Activation по едно изречение за включване.
Резултат
Това ми струва няколко часа. Оттогава първото нещо, което проверявам при нов подагент, е в кое поле гледам.
2

Всеки въпрос се задаваше по два пъти

поправено
Какво ставаше
Клиентът чуваше един и същи въпрос два пъти, в един и същи мехур текст.
Защо
Entry Point имаше право на един уточняващ въпрос. Значи задаваше въпроса И прехвърляше, а Parloa слива текста на двата агента в един мехур.
Какво направих
Entry Point вече прехвърля безшумно и не задава въпроси.
Резултат
Изчезна веднага.
3

„Онлайн или в магазина?" се връщаше при всяка тема

поправено
Какво ставаше
Човек пита за работно време, агентът пита дали поръчката е онлайн.
Защо
Въпросът беше наложен на четири места наведнъж - в персоната, в списъка с фрази, в клона за статус и в Entry Point.
Какво направих
Ограничих го само до връщане, замяна и корекция, и то на всичките четири места.
Резултат
Оттук тръгна правилото, което важи за целия проект - едно поведение може да е наложено на седем места. Или ги поправяш всичките, или не си поправил нищо.
4

Частично предаден артикул се докладваше като предаден

поправено
Какво ставаше
Казвахме на клиента, че стоката му е предадена, а тя не беше. Пет поръчани, един предаден.
Защо
Ползвах модел с три групи състояния, който не гледаше бройките. Ред с qty 5, reservedQty 0 и deliveredQty 1 не попадаше в нито една от трите групи и просто пропадаше.
Какво направих
Махнах групите и сложих сметка - поръчани минус предадени минус готови в магазина. Плюс правило, което важи и днес: deliveredQty над нула НЕ значи предаден артикул, винаги се сравнява с qty.
Резултат
reservedQty и deliveredQty се изключват взаимно - при вземане парчето излиза от едното и влиза в другото. Това трябваше да го разбера от данните, не от документация, защото такава няма.
5

Транспортът се броеше като артикул

поправено
Какво ставаше
Агентът изчиташе транспорта като пореден продукт в поръчката.
Защо
Нямаше разлика между стока и услуга. Ред с article.type Транспорт или Транспорт-вътрешен е услуга, не мебел.
Какво направих
Изключих редовете, които съвпадат с транспорт, услуга, разнасяне или монтаж - и от броя, и от изговорения списък.
Резултат
Тук научих и че recipet вътре в article са модули на същия артикул, а не отделни артикули.
6

Проверката на самоличност никога не сработваше

махнато
Какво ставаше
Потокът питаше „На кое име е поръчката, за да потвърдя?" и после казваше всичко. Пробвах с поръчка 74996249 на реален клиент Петя Кирова и име „Иван Петров" - агентът разказа цялата поръчка.
Защо
Името се събираше ПРЕДИ справката. На момента на отговора правилото за сравнение беше един ред срещу около четирийсет реда инструкции как да се форматира отговорът. На малък модел при температура нула печели доминиращият образец - обобщи резултата.
Какво направих
Махнах проверката изцяло, от трите слоя. Проверка, която никога не сработва, е по-лоша от липсваща, защото изглежда като защита.
Резултат
Приет риск - всеки с валиден номер на поръчка чува съдържанието ѝ. Истинската поправка е от страната на AMK: /api/document/{id} да приема име и да не връща нищо при разминаване. Записано като задача за бекенда.
7

Прехвърляхме към затворен кол център

поправено
Какво ставаше
Агентът прехвърляше по всяко време, включително в единайсет вечерта.
Защо
Parloa няма часовник. Дава само env, storage и request - никъде няма дата и час.
Какво направих
Направих справка get_current_time към /api/various/currentTime, която връща местно време в София, и я закачих към всичките девет подагента. Не я сложих в началния hook, а я извиквам точно в момента на прехвърлянето.
Резултат
Проверено на версия 108. Клонът „затворено" така и не го тествах - за това трябва тест след 20:00 или подменен отговор. Поисках от бекенда и dayOfWeek, защото endpoint-ът дава дата, но не дава ден от седмицата.
8

Безкраен цикъл при „искам да запазя час за доставка"

поправено
Какво ставаше
Entry Point хвърляше към Доставка, Доставка хвърляше към Зони, Зони хвърляше обратно към Доставка - докато Parloa спре и клиентът чуе „За съжаление не Ви разбрах."
Защо
Не беше сблъсък на формулировки, а дупка във възможностите. И двата подагента бяха изрично инструктирани да отказват записване, а Зони на това отгоре посочваше Доставка като собственик на задачата. Значи заявката имаше писмена причина да бъде отказана и от двамата, плюс посочен адрес, накъде да я хвърлят.
Какво направих
Четири редакции в една версия - Зони присвоява фразите за записване като свои, Зони получава изрично „никога не прехвърляй обратно", Доставка е инструктиран да не се включва при въпроси за дати и зони, и Доставка прехвърля само веднъж.
Резултат
Цикълът спря.
9

Измисли зона Бургас на клиент от София

поправено
Какво ставаше
Веднага след поправката на цикъла агентът предложи дати за зона Бургас Юг, а на въпрос защо отговори „По последната проверка излезе зона за Бургас, Юг."
Защо
В чат няма номер на обаждащия се, значи doc_city_id беше празно. Инструкцията казваше да не предлагам дати на неидентифициран клиент, но само с препратка - „следвам персоната, раздел шест". Без конкретно действие моделът пропада и си избира зона. Справката document_search вече съществуваше и вече беше описана като резервния път - подагентът просто никога не я извикваше.
Какво направих
Сложих пет абзаца НАД първата стъпка - твърда забрана да се назовава зона без върнат от справка doc_city_id, какво правя при разпознат клиент, какво питам при неразпознат, какво правя при ненамерен документ, и че винаги казвам за кой адрес важат датите.
Резултат
Махнах и „Люлин 7" от примерите, защото примерът сам по себе си беше източник на измислицата.
10

Четеше дати за грешен град - най-тежкият дефект в проекта

поправено
Какво ставаше
Документ 75170337 е на клиент в СТАРА ЗАГОРА. Агентът извика слотовете за зона 21, която е Зона 1 София Северозапад, и каза на клиента „за адрес в София, Люлин". Повтаряше се.
Защо
doc_city_id и cities[].id в зоните са две различни пространства от идентификатори. Документът дава SOF_51, зоните ползват свои номера. Нищо не може да се съвпадне, затова моделът се хвана за префикса SOF_ и го прочете като София. А SOF_ е префикс на тенанта и стои пред ВСЕКИ идентификатор в системата - документи, клиенти, артикули, градове. Три отделни забрани в конфигурацията не удържаха, включително изрична бележка, че SOF_51 е Стара Загора.
Какво направих
Един ред код - нов изходен параметър doc_city_name с името на града, плюс инструкция към Зони, че името е водещо и се съвпада срещу cities[].name.
Резултат
Проверено - слотовете тръгнаха към зона минус пет, която е Зона 1 Стара Загора. Прозата не може да победи липсващите данни. Цикълът е видимо счупен, а това четеше истински дати за грешен град с пълна увереност.
11

Инструментът се извикваше преди клиентът да каже номер

поправено
Какво ставаше
На първия ход document_search тръгваше без номер, адресът ставаше /api/document/ и връщаше 404. В протокола остава грешка, а клиентът чува „Само момент" за нищо.
Защо
Три слоя. Първо - седем защити на ниво инструмент не хванаха нищо, включително отметката Required и две условия за празна стойност. Второ - в инструкциите на умението стоеше безусловен ред „винаги извиквам метод", който бие всяко правило за питане отдругаде. Трето и истинското - инструментът се извикваше с един интервал като стойност. Не празно. Значи условието „празно" не го хваща и всяка писмена забрана е формално спазена, защото интервалът не е нито празна, нито нулева, нито измислена стойност.
Какво направих
Добавих изключение веднага след безусловния ред, а истинската поправка е три реда в Code Step-а след заявката - сравнявам поисканите цифри с върнатите и ако не съвпадат, зачерквам документа.
Резултат
Седем поредни текстови поправки не помогнаха. Търсех проблема в думите, а той беше в един байт. След тази проверка стана безопасно да включа и „Ignore failures" - 404 вече се превръща в „не намирам документ" вместо в грешка на екрана.
12

Сметката за остатъка - пет ревизии, две от тях грешни

поправено
Какво ставаше
Агентът казваше остатък 116,90 € на документ, по който всеки аванс беше платен изцяло. После пък казваше „не откривам точната сума в справката".
Защо
Две грешки в един ред. Събирах аванси в различни валути - три в евро и един от десет стотинки - и смятах остатъка срещу цялата сума. Реших, че и второто е грешка, и го поправих. Клиентът ме върна: заявка става продажба чак когато е доплатена, значи остатъкът наистина се мери срещу цялата сума. От трите дефекта, които докладвах, реален беше само един - смесването на валутите.
Какво направих
Филтрирах по валутата на документа. Добавих doc_payment_summary - един четим стринг с обща сума, платени аванси, остатък и списък на авансите, вместо три отделни числа. Допускът го свих до десет стотинки по указание на клиента.
Резултат
Проверено - „Обща сума петстотин шейсет и седем евро. Платени са четиристотин и петдесет евро аванси. Остават за доплащане сто и седемнайсет евро." Допускът от десет стотинки си остава непроверен, защото няма документ с такава разлика.
13

Кодовете на магазините се четяха буква по буква

поправено
Какво ставаше
„В София е налична в АБС, АВА, АЕВ, АОК, АПВ и АСЗ."
Защо
Кодовете идват направо от отговора на AMK и не съществуваха никъде в персоната или в инструкциите. Агентът нямаше с какво да ги разчете.
Какво направих
Сложих таблица с имената в инструкциите на умението, не в подагент - защото код може да изскочи в отговора на всяка справка. В Code Step не можеше, защото наличността идва от друга справка, а Parloa не позволява нов Code Step.
Резултат
„Артикулът е наличен в Айко Бургас, Айко Варна, Айко Булевард Европа, Айко Околовръстен път, Айко Пловдив и Айко Стара Загора." Внимавам да не ги бъркам със съкращенията на градовете в зоните - БС, ВА, ПВ - това е друга схема.
14

Грешна сума по втория път към поръчката

не е поправено
Какво ставаше
На въпрос „каква е поръчка 75096827 и колко дължа" агентът каза „Дължите деветстотин евро." Дългът е 936,92 €. Деветстотин е платеният аванс.
Защо
Този въпрос минава през друг подагент, който ползва друга справка. В нея няма никаква логика за плащания, а суровият JSON отива и към модела. Моделът прочете liability 900 и paidLiability 900 и ги изговори като дълг.
Какво направих
Два изхода - или пренасям сметката за плащанията и в тази справка, или махам суровия JSON от изхода към модела. Нито едно от двете не е направено.
Резултат
Дефектът е по-стар от теста, който го откри. Не е регресия, но е сериозен и стои отворен.
Code Step format_document_status - правилото за авансите, което не се пипа
var adv = doc.advances || []; var paid = false;
for (var i = 0; i < adv.length; i++) {
  var li = Number(adv[i].liability)     || 0;
  var pd = Number(adv[i].paidLiability) || 0;
  if (li > 0 && pd >= li) { paid = true; }
}
variables.set('doc_is_paid', paid ? 'true' : 'false');

Това съвпада точно с бизнес правилото - заявката е активна, ако поне един от авансите е платен изцяло. Никога не ползвам doc_is_paid като условие за влизане в график, защото означава само това и нищо повече.

Code Step - поправката за преждевременното извикване, три реда
var _req = String(variables.get('documentNumber')||'').replace(/\D/g,'');
var _got = doc ? String(doc.number||'').replace(/\D/g,'') : '';
if (_req === '' || _got !== _req) { doc = null; }
Code Step - нормализация, когато плащането е на горно ниво
if (doc && (!doc.advances || !doc.advances.length)) {
  var _li0 = Number(doc.liability) || 0;
  var _pd0 = Number(doc.plaidLiability != null ? doc.plaidLiability : doc.paidLiability) || 0;
  if (_li0 > 0) {
    doc.advances = [{ liability: _li0, paidLiability: _pd0, currency: doc.sumCurrency }];
  }
}

Документ без аванси се третира като документ с един аванс - самия себе си. Така не пипнах трите места, които четат авансите. И да - plaidLiability е печатна грешка в самото AMK, не моя.

Капани в Parloa, които ми взеха време

  • variables.set отказва ключ, който вече съществува. Съобщението, което стига до клиента, е „A tool execution error occurred" - при две успешни заявки. Всеки изходен ключ се слага точно по веднъж на клон.
  • Променливите на средата се сочат по вътрешен идентификатор. Напишеш ли името на ръка, се получава счупен чип.
  • Редакторът на инструкциите яде интервали и отказва Backspace, ако курсорът е сложен програмно. Едно възстановяване ми отне клик плюс 101 backspace.
  • Полето за име на умението стои точно над редактора. Един неточен клик вкара цял абзац в името му.
  • Изнасянето на резервното копие от браузъра стана само през write-ahead лога на localStorage. Четири други пътя бяха блокирани.
  • Изтекъл таймаут на скрипт не значи спрял скрипт. Един „спрял" скрипт продължи да пише и обърка две части от копието.

Какво остана отворено в Parloa

  • Създаване на тикет не съществува. Персоната и подагентът за рекламации инструктират агента да създава RMA и тикет към кол центъра. Такава справка няма.
  • Грешен месец при свободните дати. На 28 август агентът каза „на осми август", а слотовете бяха септемврийски. Клиент може да дойде месец по-рано. Не е поправено.
  • Guardrails на модела са изцяло изключени.
  • Вътрешна бележка се изчете на глас, въпреки че описанието изрично го забранява. Това е осмият случай, в който текстова забрана не удържа модела. Решението е бизнес, не техническо - или се подава само флаг без текст, или се приема.
  • Няма справка за записване в график. Всичките дванайсет справки са само за четене. delivery_times_post звучи като записване, но е срок на доставка по магазин.

Vapi - това, което е на телефона

продукция

Vapi пое продукцията и още е там. Пренесох деветте подагента като squad, направих двайсет и три инструмента и вързах SIP направо към нашия Asterisk. Причината да остане е в три неща, които никоя друга платформа не покриваше наведнъж - подновява си OAuth2 токена сама, има работещ български глас на телефона и говори SIP към нашата централа.

Цената е 0,12 долара на минута. Това е единствената причина въобще да гледам други платформи. Всичко останало работи.

Загубата при пренасянето е, че Code Step-овете ги няма. Сметката за плащанията и аритметиката за бройките сега са проза в промпт от 12 433 знака. Двайсет реда JavaScript станаха абзаци на български. Работи, но е по-чупливо.

1

Всички прехвърляния умираха наведнъж

поправено
Какво ставаше
Всеки handoff падаше с assistant-not-found.
Защо
Телефонният номер сочеше към асистент, а не към squad. Номер се връзва или с асистент, или със squad - не и с двете. Сочи ли към асистент, разговорът се върти без контекста на squad-а.
Какво направих
Върнах номера към squad-а.
Резултат
Стои като предупреждение в бележките ми. Превключи ли някой номера обратно към асистент, всичко пада наведнъж.
2

Разговорите умираха, преди да започнат

поправено
Какво ставаше
Край на разговора с Call.start.error get assistant. Няма транскрипт, цената е 0,00 долара.
Защо
Празен запис в messages. Опцията Default в блока Request Start се записва без съдържание и това чупи целия squad.
Какво направих
Махнах празните записи.
Резултат
Това е най-силната ми следа защо линията падна на 11 септември. Следа, не факт - докато го разгледам, инструментът вече не беше закачен никъде и не мога да възстановя дали тогава е бил.
3

Клиентът трябваше да си каже въпроса два пъти

поправено
Какво ставаше
Клиент казва „Свържи ме с проектант Кухни Бургас". Агентът отговаря „Свързвам ви с проектант Кухни в Бургас" и мълчи 17 секунди, без да извика инструмента. Чак след като клиентът повтори дума по дума, прехвърлянето тръгна.
Защо
Не беше в промпта. Промптът вече казваше да се извика веднага. Конфликтът беше в описанието на самия инструмент, което безусловно нареждаше първо да се пита за обект. Описанието стои във схемата на функцията, значи моделът го чете точно в момента, в който решава да вика ли. И направи компромис - изговори действието и зачака отговор на въпроса, който му е заповядано да зададе.
Какво направих
Направих описанието условно - ако обектът вече е казан, инструментът се вика веднага. Промпта не го пипнах, той си беше верен.
Резултат
Доказах го от таба Messages - има запис с роля bot без съседен tool_calls. Значи моделът е ИЗГОВОРИЛ действието, вместо да го извърши. Ако беше опитал и се беше провалил, щеше да има tool_calls и грешка. Това е най-бързият начин да различиш грешно окабеляване от грешни инструкции.
4

Прехвърлянето работи на телефона и умира в чата

платформа
Какво ставаше
В чат клиентът пита за поръчка, Entry Point прехвърля и после тишина. Записът свършва на „Handoff initiated.", статусът стои на Streaming, няма наследник.
Защо
Агентът получател наследява целия контекст, но не получава ход. firstMessageMode се задейства при начало на сесия в чата, но не след прехвърляне. На глас се задейства и в двата случая.
Какво направих
Три възможни - приложението да препраща въпроса, или отделен асистент само за чат без прехвърляния, или приемане на излишния ход. Нито едно не е приложено.
Резултат
Доказах го в собствения тестер на Vapi, значи не е от начина, по който приложението прави сесия. Първата ми хипотеза - че сесията не е вързана към squad - беше грешна. Едно допълнително съобщение буди агента мигновено, което доказва, че номерът му е бил в контекста през цялото време.
5

Една дума прекъсваше агента

поправено
Какво ставаше
Измерих го в реално обаждане. Клиентът казва „Здравейте" 0,23 секунди след като агентът започва, реже го, агентът почва отначало, пак го режат и накрая се чува само „Как мога да ви."
Защо
Нито един от деветте асистента нямаше stopSpeakingPlan. Значи по подразбиране всяка реч прекъсва.
Какво направих
Сложих две думи и секунда изчакване на всичките девет.
Резултат
Компромисът го обсъдихме - едносрично „Не!" вече не прекъсва.
6

Осем безшумни прехвърляния, които не бяха безшумни

поправено
Какво ставаше
Защо прехвърля, вместо да извика справката - и после подагентът хаби първия си ход за обявяване, вместо да работи с номера, който вече има.
Защо
Всичките осем носеха празен запис в messages. Без текст Vapi изговаря собственото си обявяване - „Прехвърлям ви към Поръчки и наличност" - което противоречи на описанието на самия инструмент, където пише „Предай безшумно".
Какво направих
Нулирах messages на всичките осем. Прехвърлянето към оператор вече си беше вярно, защото има истински текст - затова само handoff-ите се държаха зле.
Резултат
Моята поправка откри следващия дефект - виж по-долу.
7

Три подагента нямаха първа реплика

поправено
Какво ставаше
След прехвърляне - пълна тишина.
Защо
Видимата част я причиних аз. Махането на празния запис беше правилно, но обявяването на Vapi беше МАСКИРАЛО факта, че три подагента нямат своя първа реплика. Поръчки и наличност никога не млъква, защото на него му е казано дума по дума какво да каже. Специални услуги нямаше такава инструкция, а на Зони и слотове беше казано „между последващите инструменти мълчи".
Какво направих
Сложих правило за първа реплика след поемане и конкретен текст за всеки, а инструкцията за мълчание я свих така, че да не може да млъкне на първия ход.
Резултат
Измерих по три пъти преди и след - „Къде имате магазини" беше 1 от 3, стана 3 от 3. Останалите две се подобриха, но не напълно, и остатъкът е само в чата, което според поведението на платформата е очаквано. Проверка по телефона е единственото, което ще го реши.
8

Изтичането на дати - грешна теория и построен за нищо gateway

поправено
Какво ставаше
Агентът четеше дати на доставчици - „доставчикът е потвърдил дата двайсет и трети август две хиляди двайсет и шеста година" - въпреки че в промпта имаше забрана.
Защо
Първата ми диагноза беше, че 29 дати в контекста бият правилото. Измерих - справката връща 9826 знака с 29 полета за дата, в дърво на пет нива. Изнесох по-твърдо правило на знак нула в промпта и резултатът беше същият. Истинската причина я намерих при пълен тест от двайсет сценария: в промпта имаше ПЕТ правила за дати - три забраняващи и ДВЕ нареждащи. Агентът каза датата, защото му беше наредено, точно с тези думи. Моделът следва по-конкретното и по-късното правило.
Какво направих
Махнах двете нареждащи, свих трите забраняващи в едно и сложих изречението на клиента - „Доставката ще бъде изпълнена в срок. Ако се появи забавяне, оператор ще се свърже с Вас."
Резултат
Теорията ми беше грешна и бях построил цяла услуга върху нея. Агентът не беше нарушил нищо. Оттогава, преди да нарека нещо нарушение, претърсвам промпта за ВСИЧКИ правила по темата. Тези промпти са растели на пластове и съдържат правила, които се бият.
9

Операторът не вижда номера на клиента

отворено
Какво ставаше
При прехвърляне кол центърът не вижда от кой номер звъни човекът.
Защо
Прехвърлянето е bridged. Vapi държи първия крак и вдига НОВО изходящо SIP обаждане от себе си, после слепва звука. В заглавката From на новия крак стои идентичността на Vapi. Централата чете From и вижда Vapi. Номерът на клиента е на съвсем друг крак. Vapi го знае през цялото време - просто нищо не го пренася в сигнализацията.
Какво направих
Три посоки - SIP REFER вместо dial, режим с резюме в SIP заглавка, или пренос на идентичността. Клиентът вече тръгна по първата и смени и вътрешните номера. Тези промени са негови, не мои, и не са проверени от мен с живо обаждане.
Резултат
Засяга осем от деветте асистента, не само кухните. Рискът при REFER е ясен - ако централата не го приема, спират всички прехвърляния.
10

Разпознаването на речта на Automatic

поправено
Какво ставаше
Агентът отговаряше „не ви разбрах" и минаваше на английски.
Защо
Транскрайбърът беше на Automatic. При това положение българската реч се връщаше като английски безсмислици - „It's going to go versus proactant na kuhni v Plovdiv".
Какво направих
Закачих го твърдо на български.
Резултат
Изчезнаха и двата симптома наведнъж.
vapi_apply.py - Cloudflare стои пред api.vapi.ai и реже Python
KEY = os.environ["VAPI_KEY"]
BASE = "https://api.vapi.ai"
# Cloudflare връща 403 на подразбиращия се Python-urllib User-Agent.
HEADERS = {"Authorization": f"Bearer {KEY}",
           "Content-Type": "application/json",
           "User-Agent": "curl/8.7.1"}

Това го носи всеки мой скрипт. Без него всичко пада, докато curl от същата машина работи - и часове си мислиш, че ключът е грешен.

аритметиката за бройките, преместена от JavaScript в проза
КОЛИЧЕСТВА - НАЙ-ВАЖНОТО ПРАВИЛО. Всеки ред има БРОЙКИ, не само състояние.
поръчани = qty
предадени = deliveredQty, а ако липсва или е null, значи нула
готови в магазина = reservedQty, а ако липсва или е null, значи нула
остават = поръчани минус предадени минус готови в магазина
НИКОГА не приемам, че щом deliveredQty е повече от нула, артикулът е предаден.
ВИНАГИ сравнявам предадени с поръчани.
ТестРезултатКакво показа
20 сценария, преди поправките14 от 20 чистидва пъти изговорена дата, три пъти пълна тишина
20 сценария, след поправките17 от 20 чистидатите изчезнаха
преоценка със собствения ми анализатор19 от 20две от трите „грешки" бяха мои - „две хиляди" е и начало на сума, не само на година
мълчание, по три пъти на въпросот 1/3 на 3/3за „Къде имате магазини"
gateway, поръчка 751200149826 → 1267 байта29 дати станаха нула

Четири неща работеха от самото начало и изрично си ги записах да не ги пипам - маршрутизирането към всеки подагент, извикването на справките, отказът при несъществуващ номер вместо измисляне, и разчитането на изговорени цифри. Когато клиентът настоява за дата, агентът отказва безупречно.

Dograh - безплатното, което не стана

спряно

Dograh го пробвах, защото е с отворен код и щеше да падне цената на нула. Пренесох работния поток на наш сървър, направих 73 ребра и единайсет инструмента. Авторизацията я реших, всичките единайсет справки върнаха 200. И въпреки това спря.

Блокерът не беше нито авторизацията, нито справките. Блокерът е моделът. Платформата не държи модел, който да вика инструменти достатъчно надеждно на български, а без това целият поток е декорация.

1

Няма OAuth2

заобиколено
Какво ставаше
Токенът на AMK живее 300 секунди, а платформата не може да го подновява.
Защо
Dograh няма режим client_credentials. Държи само статична стойност в хранилище.
Какво направих
Написах външен скрипт, който на всеки 180 секунди подновява токена и го записва в същото хранилище.
Резултат
Работи, но е външна зависимост, която трябва да се следи. Това не е решение, а превръзка.
2

Кирилица в имената чупеше запазването

поправено
Какво ставаше
Сървърът връщаше 422, а после 409.
Защо
Имената на български се превръщат в slug, а при кирилица два различни български етикета дават един и същи slug. Значи сблъсък.
Какво направих
Минах на латински идентификатори и оставих българското само като видим етикет.
Резултат
Два отделни капана от един корен.
3

Отказ 403 се докладваше като успех

открито
Какво ставаше
Проверката за здраве връщаше status success, а реалният отговор беше 403.
Защо
Платформата гледа дали заявката е изпратена, не какво е върнала.
Какво направих
Проверявам кода на отговора сам, не вярвам на полето status.
Резултат
Това е най-опасният вид дефект - не че нещо не работи, а че ти казва, че работи.
4

Шейсет секунди и край

ограничение
Какво ставаше
Разговорът се прекъсва на около шейсетата секунда.
Защо
Твърда стена в платформата.
Какво направих
Не намерих заобиколно решение.
Резултат
За телефонна поръчка това е безсмислено малко. Реалните обаждания при нас минават три минути.
5

Моделът не вика инструментите надеждно

блокер
Какво ставаше
Пуснах двайсет сценария три пъти. Резултатите бяха 15 от 20, 7 от 20 и 13 от 20.
Защо
Моделът, а не потокът. Същият поток с по-силен модел дава други числа.
Какво направих
Нищо не може да се поправи в потока.
Резултат
Тук спрях. Разликата между 7 и 15 при един и същ поток показва, че проблемът не е в конфигурацията.

Локален модел и дообучение

спряно

Щом моделът е най-скъпото, пробвах да го сложа при нас. Събрах данни от реалните обаждания, подготвих дообучение с LoRA и измерих базата, преди да обучавам каквото и да е. Базата реши въпроса.

Прагът, който си сложих, е 95 процента успешно извикване на инструмент. Без това агентът не може да чете от AMK, а без AMK е безполезен.

Какво мерихРезултатПраг
извикване на инструмент, първи модел15,4%95%
извикване на инструмент, втори модел30,8%95%
извикване на инструмент, трети модел15,4%95%

Нито един локален модел не се доближи. Тридесет процента значи, че две от всеки три справки просто не се случват.

Освен това BgGPT в Ollama е мис-пакетиран - това го установих, докато търсех защо числата са толкова ниски. Подготвих 1718 примера, разделени на 1546 за обучение и по 86 за проверка и тест, плюс 915 за ръчно етикетиране. Спрях, защото в реалните обаждания има лични данни и изнасянето им в обучителен набор е отделен въпрос, който не е мой да го реша сам.

AMK gateway - най-силната находка

написано и тествано, не е качено

Построих малка услуга пред AMK, която изрязва каквото агентът не трябва да вижда. Построих я върху грешна теория - че датите в контекста бият правилото в промпта. Теорията беше грешна, но услугата се оказа права по друга причина - затваря наведнъж три отворени проблема: подновяването на токена, изтичането на дати и обема суров JSON.

Числото е 9826 байта на вход, 1267 байта на изход, двайсет и девет дати - нула.

gateway.py - разрешителен списък, не забранителен
# Разрешителен списък, не забранителен. Забранителният пропуска всяко ново
# поле, което AMK добави - и вече го направи: emailNotices[].time оцеля.
KEEP = ("id", "type", "kind", "number",
        "sumWithVat", "sumCurrency", "liability", "plaidLiability", "advances")

ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}")


def scrub_dates(obj):
    """Колан и тиранти - никоя ISO дата не оцелява, на никое ниво."""
    if isinstance(obj, dict):
        return {k: scrub_dates(v) for k, v in obj.items()
                if not (isinstance(v, str) and ISO_DATE.match(v))}
    if isinstance(obj, list):
        return [scrub_dates(v) for v in obj]
    return obj

Първата версия ползваше забранителен списък и изтече поле с време на шест от осем реални поръчки. Оттам дойде правилото - подборът на полета е разрешителен списък, винаги.

Какво тествах

  • Преобразуването срещу осем реални поръчки, като тестът пресмята бройките наново направо от суровите редове и ги сравнява - не проверява кода срещу самия себе си.
  • Целия HTTP път, авторизацията, преминаването и 404 - срещу местен макет на AMK.
  • На всяка поръчка проверявам, че не е оцеляла нито една дата, че дървото го няма, че бройките съвпадат и че в имената на артикулите няма нито една цифра.
Не е качен на сървъра и не е пробван срещу истинското AMK. Причината е локална - от моята машина curl стига до AMK, но Python сокетите не стигат, защото хостът се решава и на IPv4, и на IPv4-mapped IPv6, а Python пробва първо второто. Закачих си пач, който фиксира IPv4. На сървъра това не важи.

PolyAI - където сме сега

на живо на EU сървърите

PolyAI е последното, което пренесох. В началото го бях отписал, защото не намирах български глас. После се оказа, че го има - казва се Ивана - и че на EU сървърите, които са Enterprise, телефонията е отключена. Агентът беше пренесен от другия акаунт и го вдигнах наново на euw-1.

Тук имам нещо, което нямах във Vapi - справките са Python, не проза. Връщам се към детерминистичния код, който бях загубил при пренасянето от Parloa. Работното време, изговарянето на адресите, аритметиката - всичко това е код, който или работи, или гърми, а не инструкция, която моделът може да заобиколи.

1

Авторизацията не минаваше

поправено
Какво ставаше
UnauthorizedClientError при всяко извикване.
Защо
Доказах, че Keycloak приема и трите начина на авторизация - пуснах по една заявка от всеки и получих три пъти 200. Значи клиентът е наред, а записаната тайна е грешна. В полето имаше около петнайсет точки, а истинската тайна е 32 знака. Автоматично попълване от браузъра.
Какво направих
Клиентът я въведе наново. Аз не пипам тайни в полета на хостнати сайтове и това не го правя, дори да ми е разрешено.
Резултат
Минава.
2

Scope has changed from "" to "profile email"

поправено
Какво ставаше
Библиотеката отказваше токена.
Защо
oauthlib сравнява поискания с върнатия scope и при разминаване хвърля грешка.
Какво направих
Записах scope profile email.
Резултат
Минава.
3

Описваше несъществуваща поръчка като истинска

поправено
Какво ставаше
На измислен номер агентът отговаряше, че поръчката съдържа само услуги.
Защо
AMK не връща 404. Връща 200 с празен списък. Кодът ми приемаше 200 за успех.
Какво направих
Проверявам дали в отговора има номер или идентификатор и ако няма, казвам ясно: „С този номер не намирам поръчка. Моля, проверете номера."
Резултат
Проверено с реален и с измислен номер.
4

Четеше авансите като нула

поправено
Какво ставаше
Клиентът ми каза, че по заявката има платени аванси за 450 €, а агентът казваше, че няма.
Защо
Кодът четеше поле, което не съществува в отговора на AMK. Изобщо не го е имало.
Какво направих
Събирам по paidLiability, филтрирано по валутата на документа, а plaidLiability го ползвам само когато списъкът с аванси е празен.
Резултат
Клиентът го хвана, не тестът ми. Записах си и че по-рано бях „поправил" в бележките си сума, която всъщност беше вярна - върнах поправката.
5

Прехвърляше два пъти

поправено
Какво ставаше
Клиентът чуваше изречението за прехвърляне два пъти.
Защо
Правило в промпта не стига. Моделът може да извика справката два пъти в един разговор.
Какво направих
Сложих флаг в състоянието на разговора. Второто извикване вижда флага и не прави нищо.
Резултат
Поправка в код, не в текст. Това работи.
6

Повтаряше „София" десет пъти

поправено
Какво ставаше
На въпрос за адреса агентът изговаряше града по няколко пъти в едно изречение и четеше цифрите сбито и неразбираемо.
Защо
Адресът в AMK съдържа града и в полето за град, и вътре в самия адрес. Плюс съкращения, които синтезът не разчита.
Какво направих
Написах функция, която разгъва съкращенията, маха повторения град, чете последното число цифра по цифра и добавя „буква" пред буквата в номера. Отделно сложих и правила за произношение, които се прилагат ПРЕДИ синтеза.
Резултат
Проверих го с реалния запис, не с текста. В кода това не се вижда - трябва да се слуша.
7

Паузи в поздрава

от PolyAI
Какво ставаше
„Здравейте, аз съм виртуалният асистент на Айко, как мога да ... Ви помогна днес?" - с три секунди пауза в средата.
Защо
Аз намерих двоен поздрав и го поправих. Истинската причина обаче беше проблем при миграцията от US към EU сървърите и я оправи съпортът на PolyAI.
Какво направих
Моята поправка беше опит, не решение. Записвам го точно така, защото иначе излиза, че съм поправил нещо, което не съм.
Резултат
Оправено от тяхна страна.
8

Докладвах успех, без да проверя

моя грешка
Какво ставаше
Клиентът ми каза „нищо не си направил" и беше прав.
Защо
Моят push пропадна два пъти - първо заради лимита от един branch, после заради конфликт при сливането. А аз докладвах успех, без да погледна живия агент.
Какво направих
Сега проверявам срещу живия агент, преди да кажа, че нещо е готово.
Резултат
Открих и защо по-рано не можех да запазя настройките в Studio - чакащ ADK branch блокира записа. Това обяснява и как един случаен клик превключи режима и не можах да го върна.
9

Капаните на YAML

знае се
Какво ставаше
Конфигурацията се зарежда, но не прави каквото пише.
Защо
Три отделни неща. description: 11 се чете като число, не като текст. Двоеточие с интервал вътре в обикновен текст чупи разбора. Името на файла с тема трябва да съвпада с името вътре, превърнато в долни черти.
Какво направих
Многоредови блокове за всеки текст, който може да съдържа двоеточие. Имената ги проверявам, преди да валидирам.
Резултат
Нито едно от трите не дава смислено съобщение за грешка.
10

Часът на AMK е с три часа разлика

при AMK
Какво ставаше
Справката за текущо време връща час, който не е софийският.
Защо
При AMK. Не е при мен.
Какво направих
Смятам София от UTC сам, без да завися от часовите зони в средата, а часа на AMK го ползвам само за кръстосана проверка и го записвам в лога, ако се разминава.
Резултат
Решавам по моя час, не по техния. Задачата е при тяхното IT.
functions/amk_current_time.py - работното време на едно място
# ЕДИНСТВЕНОТО място, където стои часът. За тест е сложено 08:00;
# за реална работа се връща на 10. Промяната е един ред, не седем файла.
OPEN_HOUR = 8          # <-- ВРЕМЕННО ЗА ТЕСТ (реално: 10)
CLOSE_WEEKDAY = 20     # понеделник-събота
CLOSE_SUNDAY = 19      # неделя
Това е временно на 08:00, за да може да се тества прехвърлянето към оператор и проектант преди десет часа. Трябва да се върне на 10, преди да влезе реален трафик. Един ред е.

Капани в платформата, които ми струваха по една сглобка всеки

  • goto_flow само нарежда преход. Промптът на целевия поток НЕ се вижда в хода, в който маршрутизираш. Значи не можеш да разчиташ на него веднага.
  • call_handoff кара платформата да говори. Промптът след това трябва да мълчи, иначе клиентът чува две изречения.
  • Сливането на branch в main на EU качва направо на живо. Няма отделна стъпка за публикуване.
  • Лимитът е един branch. Чужд недовършен branch блокира и моя push, и записа на настройките в Studio.
  • Знанието се управлява през папка с теми, но папката се появява едва след първата тема. Докато няма тема, изглежда, че функцията не съществува.
  • Ключът за ADK е отделен за всеки регион. EU иска свой.
ТестРезултатБележка
набор на живо, EU42 от 42реални справки към AMK
набор със симулация55 от 67част от разликите са мои прекалено строги проверки
реални обаждания изчетени21 028от папките с логове
нови теми в знанието15за въпроси, които никой тест не покриваше
мъртъв въздух в реален запис35%измерено с ffmpeg
пауза преди поздрава2,7 sизмерено с ffmpeg

Най-полезното, което направих тук, беше да изчета двайсет и един хиляди реални обаждания и да видя за какво наистина звънят хората. Миксът е почти обърнат спрямо тестовия ми набор. Поръчки около 30 процента, доставка 29, магазини 15, плащания 9, връщане 8, специални 3, зони 2. А аз бях направил най-подробните тестове точно за зоните, които са два процента.

polyai-port/live_suite.py - глобални проверки за всеки ход
# Правила, които важат за ВСЕКИ отговор, независимо от сценария:
#  - никоя гола цифра в текста (всичко се изговаря с думи)
#  - нито една латинска буква
#  - нито един празен ход
#  - изречението за прехвърляне се казва точно веднъж

Около осем от „провалите" в двата набора се оказаха мои проверки, не грешки на агента. Изисквах една конкретна справка, при положение че друга дава същия отговор. Изисквах работно време без условие. Изисквах въпроса за пакетиране първи, което противоречи на задължителния въпрос за канала в самия поток. Поправих проверките, не агента.

Защо останаха само две

28 септември 2026, версия 3 на таблицата
ПлатформаOAuth2 самБългарски гласНашия AsteriskЗащо отпадна или остана
Vapiда, в продукцияда, доказанда, SIP направоостава - цената 0,12 $/мин е единственото против
PolyAIда, през техния трезорда, Иванада, на EUостава - там сме сега, 42 от 42
Dograhнепрез моделадамоделът не вика инструменти надеждно, стена на 60 секунди
Локален моделняма значениедада15,4% до 30,8% извикване на инструмент срещу праг 95%
Azure Voice Liveтрябва на ръкадаиска сертифициран SBC4-8 седмици работа, OAuth2 се пише на ръка
Genesysдалипсва във всеки гласов модулдаблокирана от езика
Copilot Studioне, писмено отказанолипсва-Microsoft пише черно на бяло, че client_credentials не се поддържа
Synthflowне, статичен токендаиска външен проксиминимум 30 000 $ на година
Amazonданяма, нито Polly, нито Nova Sonicдокументирано неподдържаноотпада и по глас, и по централа
Cognigyнедокументирано--отпада веднага
LuMay---няма никаква документация за разработчици, пилот от 25 000 $
Езиковият модел не е пречка при никоя платформа. Навсякъде може да се включи модел, който говори български. Пречката, когато я има, е в разпознаването на речта и в синтеза на гласа.

Какво остава

отворени задачи

При мен

в работа

Работното време да се върне от 08:00 на 10:00, преди да влезе реален трафик.

AMK gateway да се качи на сървъра и да се пробва срещу истинското AMK.

Телефонен тест на прехвърлянията във Vapi - в чата остатъкът е очакван, само гласът ще го реши.

Грешната сума по втория път към поръчката - или пренасям сметката, или махам суровия JSON.

При PolyAI

чака

SIP телефонията. Техен инженер гледа прихванати пакети.

Докато това не се затвори, тестваме през техния канал, не през нашата централа.

При AMK

чака

currentTime връща час с три часа разлика.

Няма справка за записване в график. Всичките дванайсет са само за четене, значи агентът може само да изчете свободни дати.

Няма справка за създаване на тикет, а потокът за рекламации го предполага.

/api/document/{id} да приема име и да не връща нищо при разминаване - това е истинската проверка на самоличност.

Решение на бизнеса

за решаване

Вътрешните бележки по документ - или не се подават на модела изобщо, а само флаг, или се приема, че могат да бъдат изчетени.

Може ли клиент с валиден номер на поръчка да чуе съдържанието ѝ без друга проверка. Сега може.