Native PHP: Автоматски тријажирајте тикети за поддршка со паметна рутирачка ВИ
Формуларот за контакт изгледа едноставно сѐ додека секоја порака не заврши во истото сандаче. Продажните прашања чекаат зад проблемите со лозинки, прашањата за наплата се префрлаат меѓу луѓето, а навистина ризичните пораки добиваат внимание само кога некој случајно ќе ги забележи.
Корисната примена на ВИ тука не е сложен чет-бот. Таа е ограничена услуга за одлучување: прегледај порака, додели една контролирана редица, забележи го образложението и безбедно примени резервна постапка секогаш кога на моделот или мрежата не може да им се верува. Во ова упатство се гради таа услуга во Native PHP 8.3 со cURL, SQLite преку PDO и Smart Routing AI Model.
Добијте пристап пред да пишувате код за интеграција
- Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Smart Routing AI Model. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
- Посетете ја официјалната документација за услугата. Најдете го панелот Service token и копирајте го токенот ограничен на услугата што е прикажан таму.
- Зачувајте го тој токен во конфигурацијата на околината на проектот. Оваа услуга бара токен; таа не е анонимна крајна точка.
Повторното генерирање на токенот за услугата го поништува претходно активниот токен. Третирајте го повторното генерирање како ротација на акредитиви: ажурирајте ја секоја распоредена инстанца, рестартирајте ги релевантните PHP работници, проверете го новиот токен и отстранете ја секоја застарена тајна од системот за распоредување.
Потврдете ја крајната точка со минимално барање
Точниот API повик е POST https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions, автентициран со Authorization: Bearer {serviceToken}. Прифаќа OpenAI-компатибилно chat барање и враќа стандарден одговор во OpenAI-стил.
Идентификаторот на моделот достапен на сметка може да зависи од активираниот план. Копирајте го поддржаниот идентификатор од официјалната документација или конфигурацијата на планот и заменете го со YOUR_PLAN_MODEL; погодувањето на име на модел би ја претворило проверката на распоредувањето во конфигурациска грешка.
curl --request POST \
--url https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--header 'Content-Type: application/json' \
--data '{
"model": "YOUR_PLAN_MODEL",
"messages": [
{
"role": "user",
"content": "Classify this contact request: I need a copy of last month'\''s invoice."
}
]
}'
Успешниот одговор треба да содржи содржина од асистентот на choices[0].message.content. Апликацијата ќе ја потврди таа патека наместо да претпостави дека секој успешен HTTP одговор содржи употреблив излез.
Создадете нејавна датотека .env по овој smoke test:
SMART_ROUTING_TOKEN=YOUR_SERVICE_TOKEN
SMART_ROUTING_MODEL=YOUR_PLAN_MODEL
DB_DSN=sqlite:/var/lib/contact-router/tickets.sqlite
Чувајте ја .env надвор од коренот на веб-документи, исклучете ја од контрола на верзии и ограничете ја на сметката што извршува PHP. Во контејнер или управуван хост, внесете ги истите имиња преку механизмот за тајни на платформата наместо да ја вградите датотеката во слика.
Изберете мала архитектура толерантна на откажувања
Прелистувачот испраќа до public/contact.php. Обработувачот го проверува формуларот, бара од наменски API клиент одлука за насочување и ги запишува пораката и одлуката во една SQLite трансакција. Достапните одредишта се sales, support, billing, abuse и manual_review.
Класификацијата е синхрона тука затоа што одржува обично распоредување за мал тим разбирливо. Повикот има строги временски ограничувања, а неуспехот нагоре не го губи тикетот: тој се насочува во manual_review. Пооптоварена инсталација може подоцна да го премести истиот клиент зад работник, но тоа додава грижи за испорака, дедупликација и операции што на овој проект инаку не му се потребни.
Користете ја оваа структура на проектот:
contact-router/
├── .env
├── bootstrap.php
├── composer.json
├── database/
│ └── schema.sql
├── public/
│ └── contact.php
├── src/
│ └── SmartRouting.php
└── tests/
└── SmartRoutingClientTest.php
На PHP му се потребни екстензиите cURL и PDO SQLite. PHPUnit е единствената развојна зависност:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"classmap": ["src/"]
}
}
composer install
composer dump-autoload
mkdir -p /var/lib/contact-router
sqlite3 /var/lib/contact-router/tickets.sqlite < database/schema.sql
vendor/bin/phpunit tests
Изградете одбранбена API граница
Транспортот подолу извршува една ограничена HTTP операција. Политиката за повторување припаѓа на клиентот од повисоко ниво за тестовите да можат да ја испитаат без да прават вистински барања.
<?php
// src/SmartRouting.php
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public array $headers = [],
) {}
}
interface HttpTransport
{
public function postJson(string $url, array $headers, array $body): HttpResponse;
}
class TransportException extends RuntimeException {}
class RoutingException extends RuntimeException {}
class RoutingConfigurationException extends RoutingException {}
final class CurlTransport implements HttpTransport
{
public function postJson(string $url, array $headers, array $body): HttpResponse
{
$responseHeaders = [];
$handle = curl_init($url);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 2000,
CURLOPT_TIMEOUT_MS => 8000,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
use (&$responseHeaders): int {
$length = strlen($line);
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return $length;
},
]);
$raw = curl_exec($handle);
if ($raw === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportException($message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $raw, $responseHeaders);
}
}
final readonly class RoutingDecision
{
public function __construct(
public string $queue,
public float $confidence,
public string $summary,
public string $source = 'model',
) {}
}
Моделот прима затворен речник и барање само за JSON. Тој потсетник ја подобрува доследноста, но не е безбедносна граница. Вратената содржина сѐ уште е недоверлив влез.
<?php
final class SmartRoutingClient
{
private const URL =
'https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions';
public function __construct(
private string $token,
private string $model,
private HttpTransport $transport,
private Closure $logger,
private Closure $sleeper,
) {}
public function classify(string $message): RoutingDecision
{
$payload = [
'model' => $this->model,
'messages' => [
[
'role' => 'system',
'content' => 'Route the contact message to exactly one queue: '
. 'sales, support, billing, or abuse. Return only JSON with '
. 'queue, confidence from 0 to 1, and a short summary.',
],
['role' => 'user', 'content' => $message],
],
];
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->postJson(
self::URL,
[
'Authorization: Bearer ' . $this->token,
'Content-Type: application/json',
],
$payload,
);
} catch (TransportException $exception) {
($this->logger)([
'outcome' => 'transport_error',
'attempt' => $attempt,
]);
if ($attempt === 3) {
throw new RoutingException('Routing service unavailable');
}
($this->sleeper)(2 ** ($attempt - 1));
continue;
}
if (in_array($response->status, [408, 429], true)
|| $response->status >= 500) {
($this->logger)([
'outcome' => 'retryable_http_error',
'status' => $response->status,
'attempt' => $attempt,
]);
if ($attempt === 3) {
throw new RoutingException('Routing service unavailable');
}
$retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
? (int) $response->headers['retry-after']
: 2 ** ($attempt - 1);
($this->sleeper)(min(5, max(1, $retryAfter)));
continue;
}
if (in_array($response->status, [401, 403], true)) {
throw new RoutingConfigurationException(
'Service token rejected'
);
}
if ($response->status < 200 || $response->status >= 300) {
throw new RoutingException(
'Non-retryable routing response: ' . $response->status
);
}
return $this->mapResponse($response->body);
}
throw new RoutingException('Routing attempts exhausted');
}
private function mapResponse(string $body): RoutingDecision
{
try {
$envelope = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
$content = $envelope['choices'][0]['message']['content'] ?? null;
if (!is_string($content)) {
throw new UnexpectedValueException('Missing assistant content');
}
$result = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException|UnexpectedValueException $exception) {
throw new RoutingException('Malformed routing response');
}
$queue = $result['queue'] ?? null;
$confidence = $result['confidence'] ?? null;
$summary = $result['summary'] ?? null;
$allowed = ['sales', 'support', 'billing', 'abuse'];
if (!in_array($queue, $allowed, true)
|| !is_int($confidence) && !is_float($confidence)
|| $confidence < 0 || $confidence > 1
|| !is_string($summary) || trim($summary) === '') {
throw new RoutingException('Invalid routing decision');
}
if ($confidence < 0.65) {
return new RoutingDecision(
'manual_review',
(float) $confidence,
substr($summary, 0, 240),
'low_confidence',
);
}
return new RoutingDecision(
$queue,
(float) $confidence,
substr($summary, 0, 240),
);
}
}
Само преодни транспортни грешки, HTTP 408, HTTP 429 и грешки на серверот добиваат ограничени повторувања. Автентикацијата и обичните клиентски грешки не се повторуваат: повторното испраќање на истото неважечко барање троши квота и го одложува посетителот. Нумеричката вредност Retry-After се почитува, но е ограничена за едно веб-барање да не остане отворено на неодредено време.
Зачувајте го тикетот и неговата одлука заедно
CREATE TABLE tickets (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
message TEXT NOT NULL,
queue TEXT NOT NULL,
confidence REAL NOT NULL,
route_summary TEXT NOT NULL,
route_source TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX tickets_queue_created
ON tickets (queue, created_at);
<?php
final class TicketRepository
{
public function __construct(private PDO $pdo) {}
public function save(
string $id,
string $email,
string $message,
RoutingDecision $decision,
): void {
$statement = $this->pdo->prepare(
'INSERT INTO tickets
(id, email, message, queue, confidence, route_summary,
route_source, created_at)
VALUES
(:id, :email, :message, :queue, :confidence, :summary,
:source, :created_at)'
);
$statement->execute([
'id' => $id,
'email' => $email,
'message' => $message,
'queue' => $decision->queue,
'confidence' => $decision->confidence,
'summary' => $decision->summary,
'source' => $decision->source,
'created_at' => gmdate('c'),
]);
}
}
Зачувајте ја изворната порака за тимот што мора да одговори на неа, но не ја праќајте адресата на е-пошта до класификаторот кога само пораката е доволна. Така се намалува непотребното откривање. Правилата за задржување и бришење треба да ги опфатат и изворниот текст и резимето создадено од моделот.
Поврзете ги конфигурацијата и крајната точка за контакт
<?php
// bootstrap.php
require __DIR__ . '/vendor/autoload.php';
$env = parse_ini_file(__DIR__ . '/.env', false, INI_SCANNER_RAW);
if ($env === false) {
throw new RuntimeException('Environment configuration is missing');
}
foreach (['SMART_ROUTING_TOKEN', 'SMART_ROUTING_MODEL', 'DB_DSN'] as $key) {
if (!isset($env[$key]) || $env[$key] === '') {
throw new RuntimeException("Missing configuration: {$key}");
}
}
$pdo = new PDO($env['DB_DSN'], null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$logger = static function (array $context): void {
error_log(json_encode(
['event' => 'smart_routing'] + $context,
JSON_THROW_ON_ERROR
));
};
$sleeper = static fn (int $seconds) => sleep($seconds);
return [
'router' => new SmartRoutingClient(
$env['SMART_ROUTING_TOKEN'],
$env['SMART_ROUTING_MODEL'],
new CurlTransport(),
$logger,
$sleeper,
),
'tickets' => new TicketRepository($pdo),
];
<?php
// public/contact.php
declare(strict_types=1);
session_start();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
$csrf = (string) ($_POST['csrf'] ?? '');
$expected = (string) ($_SESSION['csrf'] ?? '');
if ($expected === '' || !hash_equals($expected, $csrf)) {
http_response_code(403);
echo json_encode(['error' => 'Invalid form session']);
exit;
}
$email = trim((string) ($_POST['email'] ?? ''));
$message = trim((string) ($_POST['message'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)
|| strlen($email) > 254
|| strlen($message) < 10
|| strlen($message) > 10000) {
http_response_code(422);
echo json_encode(['error' => 'Check the submitted fields']);
exit;
}
$services = require dirname(__DIR__) . '/bootstrap.php';
$id = bin2hex(random_bytes(16));
try {
try {
$decision = $services['router']->classify($message);
} catch (RoutingException $exception) {
error_log(json_encode([
'event' => 'smart_routing_fallback',
'ticket_id' => $id,
'exception' => $exception::class,
]));
$decision = new RoutingDecision(
'manual_review',
0.0,
'Automatic routing unavailable',
'fallback',
);
}
$services['tickets']->save($id, $email, $message, $decision);
} catch (PDOException $exception) {
error_log(json_encode([
'event' => 'ticket_persistence_failed',
'ticket_id' => $id,
]));
http_response_code(503);
echo json_encode(['error' => 'Please try again later']);
exit;
}
http_response_code(202);
echo json_encode(['ticket_id' => $id, 'status' => 'accepted']);
Формуларот што испраќа тука мора да создаде $_SESSION['csrf'] со bin2hex(random_bytes(32)) и да го вклучи во скриено поле csrf. Додајте и ограничување на стапката на ниво на раб или апликација; CSRF заштитата не спречува автоматизиран спам.
Тестирајте без да ја повикувате услугата
Лажен транспорт ги прави тестовите за насочување брзи и детерминистички. Исто така докажува дека однесувањето при повторување случајно не станува бесконечна јамка.
<?php
// tests/SmartRoutingClientTest.php
use PHPUnit\Framework\TestCase;
final class FakeTransport implements HttpTransport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function postJson(string $url, array $headers, array $body): HttpResponse
{
$this->calls++;
return array_shift($this->responses);
}
}
final class SmartRoutingClientTest extends TestCase
{
public function testMapsBillingDecision(): void
{
$transport = new FakeTransport([
new HttpResponse(200, json_encode([
'choices' => [[
'message' => ['content' => json_encode([
'queue' => 'billing',
'confidence' => 0.93,
'summary' => 'Customer requests an invoice copy',
])],
]],
])),
]);
$client = new SmartRoutingClient(
'test-token',
'test-model',
$transport,
static fn (array $context) => null,
static fn (int $seconds) => null,
);
$decision = $client->classify('Please send last month’s invoice.');
self::assertSame('billing', $decision->queue);
self::assertSame(1, $transport->calls);
}
public function testRetriesRateLimitThenSucceeds(): void
{
$transport = new FakeTransport([
new HttpResponse(429, '{}', ['retry-after' => '1']),
new HttpResponse(200, json_encode([
'choices' => [[
'message' => ['content' => json_encode([
'queue' => 'support',
'confidence' => 0.88,
'summary' => 'Login assistance needed',
])],
]],
])),
]);
$client = new SmartRoutingClient(
'test-token',
'test-model',
$transport,
static fn (array $context) => null,
static fn (int $seconds) => null,
);
self::assertSame(
'support',
$client->classify('I cannot sign in to my account.')->queue
);
self::assertSame(2, $transport->calls);
}
}
Додајте дополнителни случаи за неправилно оформени пликови, неважечки имиња на редици, ниска сигурност, HTTP 401, три последователни преодни неуспеси и грешки во базата на податоци. Тестовите на ниво на контролер треба да потврдат дека неуспесите при насочување сѐ уште зачувуваат тикет manual_review, додека неуспесите при зачувување враќаат HTTP 503.
Управувајте со неа како со продукциска функционалност
Дневниците треба да содржат идентификатор на барање или тикет, HTTP статус, број на обид, латентност, исход и извор на резервната постапка. Никогаш не треба да содржат bearer токен, целосна порака за контакт, необработено тело од нагорниот сервис или адреса на е-пошта. Поставете предупредувања за трајни неуспеси на автентикацијата, растечки обем на резервни постапки, повторени HTTP 429 одговори и грешки при зачувување. Неколку резервни постапки се отпорност; растечка редица за резервни постапки е инцидент.
Распоредете со коренот на документи на веб-серверот поставен на public/. Осигурете PHP работникот да може да ја чита својата тајна конфигурација и да запишува во директориумот на SQLite базата на податоци, додека корисникот на веб-серверот не може да преземе ниту една од датотеките. Извршете создавање на шемата или миграции пред да го насочите сообраќајот кон новото издание. Рестартирајте ги долготрајните PHP-FPM работници по ротација на тајните во околината.
Вообичаените неуспеси обично се конкретни:
- HTTP 401 или 403: проверете го токенот ограничен на услугата, активацијата на планот и дали некој повторно го генерирал токенот.
- HTTP 429: проверете ја употребата на квота и обемот на барања. Ограничените повторувања може да апсорбираат кратко ограничување, но исцрпената квота треба да остане видлива.
- HTTP 400 или 422: проверете го конфигурираниот идентификатор на моделот и OpenAI-компатибилната JSON форма; не повторувајте непроменет влез.
- Секој тикет стигнува до рачна проверка: проверете ги дневниците за валидација на одговори, распределбата на сигурноста и дали моделот враќа проза околу побараниот JSON.
- SQLite заклучување или неуспеси при запишување: проверете ги дозволите на директориумот и обемот на истовремени запишувања. Ако запишувањата редовно се натпреваруваат, преместете го складиштето зад серверска база на податоци без да ја менувате границата за насочување.
Конечна листа за проверка
- Планот на сметката е активен и е конфигуриран документираниот идентификатор на моделот.
- Токенот постои само во конфигурација на тајни поддржана од околината.
- Минималното барање до крајната точка успева со распоредениот акредитив.
- Примерите за продажба, поддршка, наплата и злоупотреба стигнуваат до нивните наменети редици.
- Одговорите со ниска сигурност, неправилно оформени, со истечен рок и ограничени по квота стигнуваат до
manual_review. - Неуспесите на автентикацијата и валидацијата не се повторуваат слепо.
- Неуспех во базата на податоци враќа 503 наместо лажно да потврди прифаќање.
- Дневниците ги прикажуваат оперативните исходи без да изложуваат пораки или акредитиви.
- PHPUnit тестовите поминуваат пред распоредувањето, а продукциската редица за резервни постапки се следи.
Паметното насочување ја заслужува својата улога кога прави сандаче посмирено без да ги прави поднесувањата кревки. Ограничете ја овластеноста на моделот, потврдете ја секоја одлука, зачувајте го изворниот тикет и направете ја неизвесноста експлицитна редица. Производното правило што се помни е едноставно: автоматизацијата може да ја избере брзата патека, но никогаш не смее да ѝ биде дозволено да ја избрише безбедната.