Vodiči

Native PHP: Triage Support Tickets Automatically with Smart Routing AI

Native PHP: Automatski razvrstajte zahtjeve za podršku uz Smart Routing AI

Kontaktni obrazac izgleda jednostavno sve dok svaka poruka ne završi u istom ulaznom sandučiću. Upiti o prodaji čekaju iza problema s lozinkama, pitanja o naplati prebacuju se između ljudi, a poruke koje su zaista rizične dobiju pažnju tek kada ih netko slučajno primijeti.

Korisna primjena AI-ja ovdje nije složeni chatbot. To je usko usmjerena usluga za donošenje odluka: pregledati poruku, dodijeliti jedan kontrolirani red, zabilježiti obrazloženje i sigurno prijeći na pričuvno rješenje kad se modelu ili mreži ne može vjerovati. Ovaj vodič gradi tu uslugu u izvornom PHP-u 8.3 koristeći cURL, SQLite putem PDO-a i model Smart Routing AI Model.

Pribavite pristup prije pisanja integracijskog koda

  1. Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
  2. Otvorite stranicu usluge Smart Routing AI Model. Odaberite dostupni plan Free, Plus ili Pro te dovršite njegovu aktivaciju.
  3. Posjetite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu.
  4. Pohranite taj token u konfiguraciju okruženja projekta. Ova usluga zahtijeva token; nije anonimna krajnja točka.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Ponovno generiranje tretirajte kao rotaciju vjerodajnica: ažurirajte svaku implementiranu instancu, ponovno pokrenite relevantne PHP radnike, provjerite novi token i uklonite svaku zastarjelu tajnu iz svojeg sustava za implementaciju.

Potvrdite krajnju točku minimalnim zahtjevom

Točan API poziv je POST https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions, autentificiran s Authorization: Bearer {serviceToken}. Prihvaća chat zahtjev kompatibilan s OpenAI-jem i vraća standardni odgovor u stilu OpenAI-ja.

Identifikator modela dostupan računu može ovisiti o aktiviranom planu. Kopirajte podržani identifikator iz službene dokumentacije ili konfiguracije plana i zamijenite njime YOUR_PLAN_MODEL; nagađanje imena modela pretvorilo bi provjeru implementacije u pogrešku konfiguracije.

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."
      }
    ]
  }'

Uspješan odgovor trebao bi sadržavati sadržaj asistenta na choices[0].message.content. Aplikacija će provjeriti tu putanju umjesto da pretpostavi kako svaki uspješan HTTP odgovor sadrži upotrebljiv izlaz.

Nakon ovog probnog testa izradite nejavnu datoteku .env:

SMART_ROUTING_TOKEN=YOUR_SERVICE_TOKEN
SMART_ROUTING_MODEL=YOUR_PLAN_MODEL
DB_DSN=sqlite:/var/lib/contact-router/tickets.sqlite

Držite .env izvan korijena web-dokumenta, isključite je iz kontrole verzija i ograničite je na račun koji pokreće PHP. U spremniku ili na upravljanom hostu iste nazive ubrizgajte putem mehanizma za tajne platforme umjesto da datoteku ugradite u sliku.

Odaberite malu arhitekturu otpornu na kvarove

Preglednik šalje zahtjev na public/contact.php. Obradnik provjerava obrazac, traži odluku o usmjeravanju od namjenskog API klijenta i zapisuje poruku i odluku u jednu SQLite transakciju. Dostupna odredišta su sales, support, billing, abuse i manual_review.

Klasifikacija je ovdje sinkrona jer tako obična implementacija za mali tim ostaje razumljiva. Poziv ima stroga vremenska ograničenja, a kvar uzvodne usluge ne gubi prijavu: usmjerava se na manual_review. Zauzetija instalacija kasnije može smjestiti istog klijenta iza radnika, no to dodaje pitanja isporuke, deduplikacije i operativnog rada koja ovom projektu inače nisu potrebna.

Upotrijebite ovu strukturu projekta:

contact-router/
├── .env
├── bootstrap.php
├── composer.json
├── database/
│   └── schema.sql
├── public/
│   └── contact.php
├── src/
│   └── SmartRouting.php
└── tests/
    └── SmartRoutingClientTest.php

PHP treba proširenja cURL i PDO SQLite. PHPUnit je jedina razvojna ovisnost:

{
  "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

Izgradite obrambenu API granicu

Transport u nastavku izvodi jednu ograničenu HTTP operaciju. Pravila ponovnog pokušaja pripadaju klijentu više razine kako bi ih testovi mogli provjeravati bez stvarnih zahtjeva.

<?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',
    ) {}
}

Model prima zatvoren vokabular i zahtjev samo za JSON. Taj upit poboljšava dosljednost, ali nije sigurnosna granica. Vraćeni sadržaj i dalje je nepouzdan ulaz.

<?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),
        );
    }
}

Samo prolazne pogreške transporta, HTTP 408, HTTP 429 i pogreške poslužitelja dobivaju ograničena ponavljanja. Autentifikacija i uobičajene pogreške klijenta ne ponavljaju se: ponovno slanje istog neispravnog zahtjeva troši kvotu i odgađa posjetitelja. Numerička vrijednost Retry-After poštuje se, ali je ograničena kako jedan web zahtjev ne bi ostao otvoren neograničeno dugo.

Trajno pohranite prijavu i njezinu odluku zajedno

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'),
        ]);
    }
}

Pohranite izvornu poruku za tim koji na nju mora odgovoriti, ali ne šaljite adresu e-pošte klasifikatoru kada je sama poruka dovoljna. Time se smanjuje nepotrebno otkrivanje podataka. Pravila zadržavanja i brisanja trebala bi obuhvatiti i izvorni tekst i sažetak koji je izradio model.

Povežite konfiguraciju i krajnju točku za kontakt

<?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']);

Obrazac koji ovdje šalje podatke mora izraditi $_SESSION['csrf'] pomoću bin2hex(random_bytes(32)) i uključiti ga u skriveno polje csrf. Dodajte i ograničenje broja zahtjeva na rubnoj ili aplikacijskoj razini; CSRF zaštita ne sprječava automatizirani spam.

Testirajte bez pozivanja usluge

Lažni transport čini testove usmjeravanja brzim i determinističkim. Također dokazuje da se ponašanje ponovnog pokušaja slučajno ne pretvara u beskonačnu petlju.

<?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);
    }
}

Dodajte dodatne slučajeve za neispravne omotnice, nevažeće nazive redova, nisku pouzdanost, HTTP 401, tri uzastopna prolazna kvara i pogreške baze podataka. Testovi na razini kontrolera trebali bi potvrditi da kvarovi usmjeravanja i dalje trajno pohranjuju prijavu manual_review, dok kvarovi trajne pohrane vraćaju HTTP 503.

Upravljajte njime kao produkcijskom značajkom

Zapisnici bi trebali sadržavati identifikator zahtjeva ili prijave, HTTP status, broj pokušaja, latenciju, ishod i izvor pričuvnog rješenja. Nikada ne bi smjeli sadržavati bearer token, cijelu kontaktnu poruku, neobrađeno tijelo uzvodnog odgovora ni adresu e-pošte. Postavite upozorenja za trajne kvarove autentifikacije, rastući broj pričuvnih usmjeravanja, ponavljane HTTP 429 odgovore i pogreške trajne pohrane. Nekoliko pričuvnih usmjeravanja znači otpornost; rastući red pričuvnih usmjeravanja znači incident.

Implementirajte s korijenom dokumenata web-poslužitelja postavljenim na public/. Osigurajte da PHP radnik može pročitati svoju tajnu konfiguraciju i pisati u direktorij SQLite baze podataka, dok korisnik web-poslužitelja ne može preuzeti nijednu datoteku. Pokrenite stvaranje sheme ili migracije prije usmjeravanja prometa na novo izdanje. Nakon rotacije tajni okruženja ponovno pokrenite dugotrajne PHP-FPM radnike.

Uobičajeni kvarovi obično su konkretni:

  • HTTP 401 ili 403: provjerite token ograničen na uslugu, aktivaciju plana i je li netko ponovno generirao token.
  • HTTP 429: pregledajte upotrebu kvote i volumen zahtjeva. Ograničeni ponovni pokušaji mogu ublažiti kratko ograničenje, ali iscrpljena kvota mora ostati vidljiva.
  • HTTP 400 ili 422: provjerite konfigurirani identifikator modela i JSON oblik kompatibilan s OpenAI-jem; ne ponavljajte nepromijenjeni ulaz.
  • Svaka prijava završava u ručnoj provjeri: pregledajte zapisnike provjere odgovora, raspodjelu pouzdanosti i vraća li model prozni tekst oko zatraženog JSON-a.
  • SQLite zaključavanje ili kvarovi pisanja: provjerite dozvole direktorija i volumen istodobnih upisa. Ako se upisi redovito sukobljavaju, premjestite repozitorij iza poslužiteljske baze podataka bez promjene granice usmjeravanja.

Završni kontrolni popis za provjeru

  • Plan računa je aktivan, a dokumentirani identifikator modela konfiguriran je.
  • Token postoji samo u tajnoj konfiguraciji podržanoj okruženjem.
  • Minimalni zahtjev krajnjoj točki uspijeva s implementiranom vjerodajnicom.
  • Primjeri prodaje, podrške, naplate i zlouporabe dolaze do predviđenih redova.
  • Odgovori s niskom pouzdanošću, neispravni, s isteklim vremenom i ograničeni kvotom dolaze do manual_review.
  • Kvarovi autentifikacije i provjere ne ponavljaju se naslijepo.
  • Kvar baze podataka vraća 503 umjesto da lažno potvrdi prihvaćanje.
  • Zapisnici otkrivaju operativne ishode bez otkrivanja poruka ili vjerodajnica.
  • PHPUnit testovi prolaze prije implementacije, a produkcijski red pričuvnih usmjeravanja nadzire se.

Pametno usmjeravanje zaslužuje svoje mjesto kada ulazni sandučić čini mirnijim, a da pritom prijave ne čini krhkima. Zadržite ovlasti modela uskim, provjerite svaku odluku, sačuvajte izvornu prijavu i neizvjesnost učinite eksplicitnim redom. Produkcijsko pravilo koje se pamti jednostavno je: automatizacija može odabrati brzi put, ali joj se nikada ne smije dopustiti da izbriše siguran.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.