Vodiči

Native PHP 8.3: AI Draft Replies for Inbox, Keeping You in Control

Nativni PHP 8.3: nacrti odgovora uz pomoć umjetne inteligencije za Inbox, uz zadržavanje vaše kontrole

Odgovor AI-ja postaje koristan tek kada se ponaša kao pažljiv pomoćnik, a ne kao autonomni pošiljatelj. Za ulaznu poštu malog poduzeća najsigurnija je granica jednostavna: model predlaže tekst, aplikacija ga pohranjuje zasebno, a član osoblja ga pregledava, uređuje i šalje kroz postojeći tijek rada.

Ovaj vodič gradi tu granicu u Native PHP 8.3. Integracija koristi krajnju točku kompatibilnu s OpenAI-jem modela Smart Routing AI Model, izvorni cURL, SQLite za kompaktnu primjerenu ulaznu poštu, obrambeno mapiranje odgovora, ograničene ponovne pokušaje, PHPUnit testove i eksplicitna stanja neuspjeha. Nijedan generirani tekst ne šalje se automatski.

Pribavite pristup prije pisanja integracijskog koda

Započnite registracijom računa ili upotrijebite stranicu za prijavu ako ga već imate.

  1. Otvorite stranicu usluge Smart Routing AI Model.
  2. Odaberite dostupni paket Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
  5. Također upotrijebite dokumentaciju kako biste potvrdili identifikator modela koji podržava vaš aktivirani paket.

Ova usluga zahtijeva token. Autentikacija koristi Authorization: Bearer {serviceToken}. Ponovno generiranje servisnog tokena opoziva prethodno aktivni token, pa rotacija tokena mora ažurirati svaku implementiranu instancu koja poziva uslugu.

Točan zahtjev je POST https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions. Prije izrade značajke pošaljite jedan minimalni zahtjev. Lokalno zamijenite oba rezervirana mjesta; nikada ne predajte rezultirajuću naredbu sa stvarnom vjerodajnicom.

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_DOCUMENTED_MODEL",
    "messages": [
      {"role": "user", "content": "Draft a polite acknowledgement of a contact request."}
    ]
  }'

Uspješan odgovor slijedi standardni JSON oblik u stilu OpenAI-ja. Implementacija će obrambeno čitati choices[0].message.content umjesto da pretpostavi kako je svako tijelo koje izgleda uspješno upotrebljivo.

Pripremite projekt Native PHP

Potrebni su vam PHP 8.3 ili noviji s cURL-om, PDO SQLiteom, JSON-om, Composerom i zapisivim direktorijem podataka aplikacije. Jedini paket za izvođenje je vlucas/phpdotenv u kompatibilnom rasponu ^5.6. PHPUnit ^11.0 pruža pokretač testova.

mkdir inbox-drafts
cd inbox-drafts
composer init --name=example/inbox-drafts --no-interaction
composer require vlucas/phpdotenv:^5.6
composer require --dev phpunit/phpunit:^11.0
mkdir -p src public var tests
composer config autoload.psr-4.App\\ src/
composer dump-autoload

Izradite .env za lokalni razvoj i isključite ga iz kontrole verzija. Produkcija treba ubrizgavati iste varijable putem upravitelja procesa ili spremišta tajni umjesto kopiranja datoteke razvojnog programera na poslužitelj.

SMART_ROUTING_TOKEN=YOUR_SERVICE_TOKEN
SMART_ROUTING_MODEL=YOUR_DOCUMENTED_MODEL
APP_DB_PATH=var/inbox.sqlite
APP_ENV=development

Projekt ima četiri granice: kontroler autentificira zahtjev osoblja, repozitorij upravlja podacima ulazne pošte, aplikacijska usluga koordinira izradu skice, a samo API klijent razumije udaljeni HTTP ugovor. To košta nekoliko malih klasa, ali sprječava da detalji cURL-a, izlaz modela i ažuriranja baze podataka procure u jednu krhku skriptu.

inbox-drafts/
  public/draft.php
  src/AI/SmartRoutingClient.php
  src/Inbox/InboxRepository.php
  src/Inbox/DraftReplyService.php
  tests/SmartRoutingClientTest.php
  var/inbox.sqlite
  .env
  composer.json

Izradite pohranu ulazne pošte s granicom pregleda

Važan stupac je draft_status. Generirani sadržaj ulazi u pending_review; nikada ne ulazi u stanje poslanog. Produkcijska ulazna pošta kasnije može povezati svoje postojeće sučelje za pregled i slanje s ovim poljima.

CREATE TABLE contact_messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    sender_name TEXT NOT NULL,
    sender_email TEXT NOT NULL,
    subject TEXT NOT NULL,
    body TEXT NOT NULL,
    ai_draft TEXT,
    draft_status TEXT,
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO contact_messages
    (sender_name, sender_email, subject, body)
VALUES
    ('Jordan', '[email protected]', 'Saturday appointment',
     'Do you have any appointments available this Saturday?');

Izgradite obrambenu API granicu

Sljedeća datoteka zajedno sadrži transportni spoj i klijent radi čitljivosti. U većoj bazi koda svaki javni tip može imati vlastitu datoteku. cURL prima ograničenje povezivanja od tri sekunde i ukupno ograničenje od dvadeset sekundi. Klijent ponovno pokušava kod mrežnih neuspjeha, HTTP-a 429 i poslužiteljskih pogrešaka, ali nikada kod neuspjeha validacije ili autentikacije.

<?php
// src/AI/SmartRoutingClient.php
declare(strict_types=1);

namespace App\AI;

final class TransportException extends \RuntimeException {}

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

interface Transport
{
    public function postJson(string $url, array $headers, array $body): HttpResponse;
}

final class CurlTransport implements Transport
{
    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 => 3,
            CURLOPT_TIMEOUT => 20,
            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;
            },
        ]);

        $bodyText = curl_exec($handle);
        if ($bodyText === 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, $responseHeaders, $bodyText);
    }
}

final readonly class DraftReply
{
    public function __construct(public string $content) {}
}

final class AiFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly bool $retryable,
        string $message,
    ) {
        parent::__construct($message);
    }
}

final class SmartRoutingClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions';

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        private readonly string $model,
        private readonly \Closure $logger,
        private readonly \Closure $sleeper,
    ) {}

    public function draft(string $customerMessage): DraftReply
    {
        $payload = [
            'model' => $this->model,
            'messages' => [
                [
                    'role' => 'system',
                    'content' => 'Draft a concise, courteous business reply. '
                        . 'Do not claim an action was completed. Do not invent '
                        . 'prices, availability, policies, or commitments. '
                        . 'Return reply text only for human review.',
                ],
                [
                    'role' => 'user',
                    'content' => "Customer message:\n---\n"
                        . $customerMessage . "\n---",
                ],
            ],
        ];

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Content-Type: application/json',
                    ],
                    $payload,
                );
            } catch (TransportException $exception) {
                ($this->logger)('ai_transport_failure', [
                    'attempt' => $attempt,
                    'error_type' => $exception::class,
                ]);

                if ($attempt === 3) {
                    throw new AiFailure(
                        'transport',
                        true,
                        'The drafting service could not be reached.'
                    );
                }

                ($this->sleeper)($attempt);
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                return $this->mapResponse($response->body);
            }

            if (in_array($response->status, [401, 403], true)) {
                throw new AiFailure(
                    'authentication',
                    false,
                    'The drafting service rejected its credential.'
                );
            }

            $retryable = $response->status === 429
                || $response->status >= 500;

            ($this->logger)('ai_http_failure', [
                'attempt' => $attempt,
                'status' => $response->status,
            ]);

            if (!$retryable || $attempt === 3) {
                $kind = $response->status === 429 ? 'quota_or_rate_limit' : 'http';
                throw new AiFailure(
                    $kind,
                    $retryable,
                    'The drafting service did not accept the request.'
                );
            }

            ($this->sleeper)($attempt);
        }

        throw new AiFailure('internal', false, 'Unexpected drafting failure.');
    }

    private function mapResponse(string $body): DraftReply
    {
        try {
            $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            throw new AiFailure(
                'invalid_response',
                false,
                'The drafting service returned invalid JSON.'
            );
        }

        $content = $decoded['choices'][0]['message']['content'] ?? null;
        if (!is_string($content) || trim($content) === '') {
            throw new AiFailure(
                'invalid_response',
                false,
                'The drafting response contained no usable text.'
            );
        }

        return new DraftReply(trim($content));
    }
}

Funkcija spavanja trebala bi implementirati malo ograničeno postupno čekanje. Na primjer, proslijedite zatvaranje koje spava jednu sekundu nakon prvog ponovnog pokušaja i dvije sekunde nakon drugog. Zadržavanje mogućnosti ubrizgavanja čini testove trenutačnima i determinističkima. Status 429 može značiti privremeno ograničenje stope ili iscrpljenu kvotu; klijent radi samo ograničene pokušaje, a zatim izlaže specifičnu vrstu neuspjeha umjesto da se vrti u petlji.

Mapirajte skicu u domenu ulazne pošte

Aplikacijska usluga dohvaća samo poruku korisnika, zahtijeva skicu i transakcijski pohranjuje rezultat. Adrese e-pošte namjerno su isključene iz upita jer ne dodaju vrijednost pri izradi skice.

<?php
// src/Inbox/InboxRepository.php and src/Inbox/DraftReplyService.php
declare(strict_types=1);

namespace App\Inbox;

use App\AI\SmartRoutingClient;

final class InboxRepository
{
    public function __construct(private readonly \PDO $pdo) {}

    public function messageBody(int $id): ?string
    {
        $statement = $this->pdo->prepare(
            'SELECT body FROM contact_messages WHERE id = :id'
        );
        $statement->execute(['id' => $id]);
        $body = $statement->fetchColumn();

        return is_string($body) ? $body : null;
    }

    public function savePendingDraft(int $id, string $draft): void
    {
        $statement = $this->pdo->prepare(
            "UPDATE contact_messages
             SET ai_draft = :draft,
                 draft_status = 'pending_review',
                 updated_at = CURRENT_TIMESTAMP
             WHERE id = :id"
        );
        $statement->execute(['draft' => $draft, 'id' => $id]);

        if ($statement->rowCount() !== 1) {
            throw new \RuntimeException('Message disappeared before draft save.');
        }
    }
}

final class DraftReplyService
{
    public function __construct(
        private readonly InboxRepository $inbox,
        private readonly SmartRoutingClient $client,
    ) {}

    public function create(int $messageId): string
    {
        $body = $this->inbox->messageBody($messageId);
        if ($body === null) {
            throw new \OutOfBoundsException('Contact message not found.');
        }

        $draft = $this->client->draft($body);
        $this->inbox->savePendingDraft($messageId, $draft->content);

        return $draft->content;
    }
}

Izložite radnju izrade skice samo za osoblje

Krajnja točka u nastavku pretpostavlja da je tijek prijave ulazne pošte već pohranio identifikator člana osoblja i CSRF token u sesiju. Prihvaća ID poruke, sinkrono izrađuje skicu i vraća JSON za zaslon pregleda. Nikada ne prihvaća proizvoljan tekst upita i nema mogućnost slanja.

<?php
// public/draft.php
declare(strict_types=1);

use App\AI\AiFailure;
use App\AI\CurlTransport;
use App\AI\SmartRoutingClient;
use App\Inbox\DraftReplyService;
use App\Inbox\InboxRepository;
use Dotenv\Dotenv;

require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
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 = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!isset($_SESSION['staff_user_id'], $_SESSION['csrf_token'])
    || !hash_equals($_SESSION['csrf_token'], $csrf)) {
    http_response_code(403);
    echo json_encode(['error' => 'forbidden']);
    exit;
}

$input = json_decode(file_get_contents('php://input'), true);
$messageId = filter_var($input['message_id'] ?? null, FILTER_VALIDATE_INT);
if ($messageId === false || $messageId < 1) {
    http_response_code(422);
    echo json_encode(['error' => 'invalid_message_id']);
    exit;
}

$token = $_ENV['SMART_ROUTING_TOKEN'] ?? '';
$model = $_ENV['SMART_ROUTING_MODEL'] ?? '';
if ($token === '' || $model === '') {
    http_response_code(503);
    echo json_encode(['error' => 'service_not_configured']);
    exit;
}

$database = $_ENV['APP_DB_PATH'] ?? 'var/inbox.sqlite';
if (!str_starts_with($database, '/')) {
    $database = dirname(__DIR__) . '/' . $database;
}

$pdo = new PDO('sqlite:' . $database, null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

$logger = static function (string $event, array $context): void {
    error_log(json_encode(
        ['event' => $event] + $context,
        JSON_THROW_ON_ERROR
    ));
};

$sleeper = static function (int $attempt): void {
    sleep(min($attempt, 2));
};

$client = new SmartRoutingClient(
    new CurlTransport(),
    $token,
    $model,
    $logger,
    $sleeper,
);

$service = new DraftReplyService(new InboxRepository($pdo), $client);

try {
    $draft = $service->create($messageId);
    echo json_encode([
        'draft' => $draft,
        'status' => 'pending_review',
    ], JSON_THROW_ON_ERROR);
} catch (OutOfBoundsException) {
    http_response_code(404);
    echo json_encode(['error' => 'message_not_found']);
} catch (AiFailure $failure) {
    http_response_code($failure->kind === 'authentication' ? 503 : 502);
    echo json_encode([
        'error' => 'draft_unavailable',
        'reason' => $failure->kind,
        'retryable' => $failure->retryable,
    ], JSON_THROW_ON_ERROR);
}

Testirajte ponovne pokušaje i mapiranje odgovora bez mreže

Lažni transport bolji je od simulirane cURL funkcije: provjerava ponašanje klijenta na HTTP granici, a pritom ostaje deterministički. Ovi testovi pokrivaju oporavak, politiku autentikacije i neispravne uspješne odgovore.

<?php
// tests/SmartRoutingClientTest.php
declare(strict_types=1);

use App\AI\AiFailure;
use App\AI\HttpResponse;
use App\AI\SmartRoutingClient;
use App\AI\Transport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    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
{
    private function client(FakeTransport $transport): SmartRoutingClient
    {
        return new SmartRoutingClient(
            $transport,
            'test-token',
            'documented-model',
            static fn () => null,
            static fn () => null,
        );
    }

    public function testRetriesServerFailureAndMapsDraft(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(503, [], '{}'),
            new HttpResponse(200, [], json_encode([
                'choices' => [[
                    'message' => ['content' => '  Thanks for contacting us.  '],
                ]],
            ], JSON_THROW_ON_ERROR)),
        ]);

        self::assertSame(
            'Thanks for contacting us.',
            $this->client($fake)->draft('Hello')->content,
        );
        self::assertSame(2, $fake->calls);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([new HttpResponse(401, [], '{}')]);

        try {
            $this->client($fake)->draft('Hello');
            self::fail('Expected AiFailure');
        } catch (AiFailure $failure) {
            self::assertSame('authentication', $failure->kind);
            self::assertFalse($failure->retryable);
            self::assertSame(1, $fake->calls);
        }
    }

    public function testRejectsMalformedSuccessfulResponse(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(200, [], '{"choices":[]}'),
        ]);

        $this->expectException(AiFailure::class);
        $this->client($fake)->draft('Hello');
    }
}
vendor/bin/phpunit tests

Sigurnost, vidljivost i implementacija

Poruke korisnika tretirajte kao osjetljive podatke. Šaljite samo ono što je modelu potrebno, zadržite omogućenu enkripciju prijenosa, ograničite rutu za skice na autentificirano osoblje, zadržite CSRF zaštitu i definirajte odgovarajuću politiku zadržavanja pohranjenih skica. Generirani tekst prikazujte s HTML izbjegavanjem u sučelju za pregled. Izlaz modela je nepouzdan sadržaj, čak i kada upit traži običan tekst.

Zapisnici trebaju sadržavati nazive događaja, HTTP status, broj pokušaja, latenciju i interni identifikator poruke kada je primjereno. Ne smiju sadržavati bearer token, poruku korisnika, generirani odgovor, adresu e-pošte ni potpuno udaljeno tijelo. Postavite upozorenja za trajne neuspjehe autentikacije, ponovljene odgovore 429, neispravne uspješne odgovore i povećane poslužiteljske neuspjehe.

Implementirajte s omogućenim cURL-om i PDO SQLiteom, pokrenite testove, inicijalizirajte shemu i osigurajte da samo PHP proces može pisati u direktorij baze podataka. Ubrizgajte token i model putem tajni implementacije. Ako se token ponovno generira, ažurirajte tajnu i ponovno pokrenite ili učitajte svakog PHP radnika kako nijedan proces ne bi zadržao zastarjelu konfiguraciju.

Za prometniju ulaznu poštu premjestite izradu skica u pokretač poslova kako stranica za pregled ne bi čekala latenciju modela. Očuvajte istu granicu klijenta i domene, dodajte provjeru idempotentnosti prije spremanja i prikažite vidljivo stanje „izrada skice”. Nemojte dodavati red čekanja samo kako biste prikrili nedostatak rukovanja vremenskim ograničenjima i neuspjesima.

Uobičajeni neuspjesi za koje vrijedi dizajnirati

  • 401 ili 403: provjerite token ograničen na uslugu, aktivaciju paketa i tajnu implementacije. Nemojte automatski ponovno pokušavati.
  • 429: zaustavite se nakon ograničenog postupnog čekanja. Zamolite pregledavatelja da pokuša ponovno kasnije i provjerite kvotu paketa ili stopu zahtjeva.
  • Neuspjeh validacije klase 400: usporedite vrijednost modela i JSON zahtjev sa službenom dokumentacijom. Ponovni pokušaj nepromijenjenog zahtjeva neće ga popraviti.
  • Prazan ili neispravan uspjeh: postojeću skicu ostavite netaknutom i prijavite invalid_response.
  • Istek vremena ili 5xx: ponovno pokušajte samo nekoliko puta, a zatim sučelju vratite oporavljiv neuspjeh.
  • Nepouzdano formuliranje: zahtijevajte ljudski pregled, prikažite izvornu poruku pored skice i nikada ne pretvarajte skicu u automatsku obvezu.

Završni popis za provjeru

  1. Aktivirani paket, dokumentirana vrijednost modela i trenutačni servisni token konfigurirani su.
  2. Minimalni autentificirani zahtjev uspijeva prema točnoj krajnjoj točki za chat-completions.
  3. Nijedna vjerodajnica ne pojavljuje se u kontroli izvornog koda, testnim podacima, zapisnicima aplikacije ni odgovorima preglednika.
  4. PHPUnit provjerava uspješno mapiranje, ograničeni ponovni pokušaj i neponovljivi neuspjeh autentikacije.
  5. Ruta za osoblje odbija neautentificirane zahtjeve, zahtjeve koji nisu POST, neispravne zahtjeve i zahtjeve s nepodudarnim CSRF-om.
  6. Generirani odgovor pohranjuje se samo kao pending_review.
  7. Zaslon pregleda izbjegava izlaz i zahtijeva da ga osoba uredi ili odobri prije slanja.
  8. Produkcijsko nadziranje razlikuje neuspjehe autentikacije, kvote, prijenosa, HTTP-a i neispravnog odgovora.

Najjača značajka ovdje nije tečan tekst. To je namjerni razmak između prijedloga i radnje. Uz usku API granicu, obrambeno mapiranje, iskrena stanja neuspjeha i stanje baze podataka samo za pregled, AI može ukloniti teret prazne stranice iz obične ulazne pošte bez uklanjanja osobe čija prosudba štiti odnos s korisnikom.

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.