Native PHP 8.3: Нацрт-одговори со ВИ за Inbox, со целосна контрола кај вас
Одговорот од ВИ станува корисен само кога се однесува како внимателен асистент, а не како автономен испраќач. За сандаче на мал бизнис, најбезбедната граница е едноставна: моделот предлага текст, апликацијата го складира одделно, а член на персоналот го прегледува, уредува и испраќа преку постојниот работен тек.
Овој туторијал ја гради таа граница во Native PHP 8.3. Интеграцијата ја користи крајната точка компатибилна со OpenAI на Smart Routing AI Model, изворен cURL, SQLite за компактно пример-сандаче, дефанзивно мапирање на одговорите, ограничени повторни обиди, PHPUnit тестови и експлицитни состојби на неуспех. Ниту еден генериран текст не се испраќа автоматски.
Добијте пристап пред да напишете код за интеграција
Почнете со регистрација на сметка, или користете ја страницата за најава ако веќе имате сметка.
- Отворете ја страницата на услугата Smart Routing AI Model.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Најдете го панелот Service token и копирајте го токенот ограничен на услугата.
- Исто така, користете ја документацијата за да го потврдите идентификаторот на моделот поддржан од вашиот активиран план.
Оваа услуга бара токен. Автентикацијата користи Authorization: Bearer {serviceToken}. Повторното генерирање на сервисниот токен го поништува претходно активниот токен, па ротацијата на токени мора да ја ажурира секоја распоредена инстанца што ја повикува услугата.
Точното барање е POST https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions. Пред да ја изградите функционалноста, направете едно минимално барање. Заменете ги двата местодржачи локално; никогаш не ја зачувувајте во commit добиената команда со вистински акредитив.
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."}
]
}'
Успешниот одговор ја следи стандардната JSON-структура во стилот на OpenAI. Имплементацијата дефанзивно ќе чита choices[0].message.content наместо да претпоставува дека секое тело што изгледа успешно е употребливо.
Подгответе го Native PHP проектот
Потребен ви е PHP 8.3 или понов со cURL, PDO SQLite, JSON, Composer и директориум за податоци на апликацијата со дозвола за запишување. Единствениот runtime пакет е vlucas/phpdotenv во компатибилниот опсег ^5.6. PHPUnit ^11.0 го обезбедува извршувачот на тестови.
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
Креирајте .env за локален развој и исклучете го од контрола на верзии. Во продукција, истите променливи треба да се внесат преку управувачот на процеси или складиштето за тајни, наместо да се копира датотеката на развивачот на серверот.
SMART_ROUTING_TOKEN=YOUR_SERVICE_TOKEN
SMART_ROUTING_MODEL=YOUR_DOCUMENTED_MODEL
APP_DB_PATH=var/inbox.sqlite
APP_ENV=development
Проектот има четири граници: контролерот го автентицира барањето на персоналот, репозиториумот управува со податоците на сандачето, апликациската услуга ја координира изработката на нацрт, а само API-клиентот го разбира далечинскиот HTTP-договор. Ова чини неколку мали класи, но спречува деталите на cURL, излезот од моделот и ажурирањата на базата да протекуваат во една кревка скрипта.
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
Креирајте складиште за сандачето со граница за преглед
Важната колона е draft_status. Генерираната содржина влегува во pending_review; никогаш не влегува во состојба на испратено. Продукциско сандаче подоцна може да ги поврзе својот постоечки интерфејс за преглед и испраќање со овие полиња.
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?');
Изградете дефанзивна API-граница
Следната датотека ги содржи транспортниот слој и клиентот заедно, заради читливост. Во поголема база на код, секој јавен тип може да има сопствена датотека. cURL добива ограничување од три секунди за поврзување и дваесет секунди вкупно. Клиентот повторува при мрежни неуспеси, HTTP 429 и грешки на серверот, но никогаш при неуспеси на валидација или автентикација.
<?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));
}
}
Функцијата за пауза треба да имплементира мало ограничено постепено зголемување на чекањето. На пример, проследете closure што спие една секунда по првиот повторен обид и две секунди по вториот. Тоа што може да се вметне ја прави тестовите непосредни и детерминистички. Кодот 429 може да значи привремено ограничување на стапката или исцрпена квота; клиентот прави само ограничен број обиди, а потоа изложува специфичен вид неуспех наместо да врти во циклус.
Мапирајте го нацртот во доменот на сандачето
Апликациската услуга ја презема само пораката на клиентот, бара нацрт и го зачувува резултатот трансакциски. Адресите на е-пошта намерно се исклучени од промптот бидејќи не додаваат вредност за составување нацрт.
<?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;
}
}
Изложете дејство за нацрт само за персоналот
Крајната точка подолу претпоставува дека најавниот тек на сандачето веќе зачувал идентификатор на персоналот и CSRF токен во сесијата. Таа прифаќа ID на порака, синхроно создава нацрт и враќа JSON за екранот за преглед. Никогаш не прифаќа произволен текст за промпт и нема можност за испраќање.
<?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);
}
Тестирајте повторни обиди и мапирање на одговорите без мрежата
Лажниот транспорт е подобар од симулирана cURL-функција: го проверува однесувањето на клиентот на HTTP-границата, а притоа останува детерминистички. Овие тестови покриваат закрепнување, политика за автентикација и неисправни успешни одговори.
<?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
Безбедност, набљудливост и распоредување
Третирајте ги пораките на клиентите како чувствителни податоци. Испраќајте само што му е потребно на моделот, држете го шифрирањето на транспортот овозможено, ограничете ја рутата за нацрти на автентициран персонал, задржете ја CSRF-заштитата и дефинирајте соодветна политика за задржување на зачуваните нацрти. Прикажувајте го генерираниот текст со HTML-escaping во интерфејсот за преглед. Излезот од моделот е недоверлива содржина, дури и кога промптот бара обичен текст.
Дневниците треба да содржат имиња на настани, HTTP-статус, број на обиди, латентност и, каде што е соодветно, внатрешен идентификатор на порака. Не треба да содржат bearer токен, порака од клиентот, генериран одговор, адреса на е-пошта или целосно далечинско тело. Алармирајте при трајни неуспеси на автентикација, повторени одговори 429, неисправни успешни одговори и зголемени неуспеси на серверот.
Распоредете со овозможени cURL и PDO SQLite, извршете тестови, иницијализирајте ја шемата и осигурете само PHP-процесот да може да запишува во директориумот на базата. Внесете ги токенот и моделот преку тајните за распоредување. Ако токенот е повторно генериран, ажурирајте ја тајната и рестартирајте или повторно вчитајте го секој PHP worker за ниеден процес да не задржи застарена конфигурација.
За пооптоварено сандаче, преместете го создавањето на нацрти во извршувач на задачи за страницата за преглед да не чека латентност од моделот. Задржете ја истата граница на клиентот и доменот, додајте проверка на идемпотентност пред зачувувањето и изложете видлива состојба „се изработува нацрт“. Не додавајте редица само за да прикриете недостиг на обработка на тајмаути и неуспеси.
Вообичаени неуспеси за кои вреди да се проектира
- 401 или 403: проверете ги токенот ограничен на услугата, активацијата на планот и тајната за распоредување. Не повторувајте автоматски.
- 429: застанете по ограниченото постепено чекање. Побарајте од прегледувачот да се обиде подоцна и проверете ја квотата на планот или стапката на барања.
- Неуспех на валидација од класата 400: споредете ја вредноста на моделот и JSON-барањето со официјалната документација. Повторувањето на непроменето барање нема да го поправи.
- Празен или неисправен успех: задржете го постојниот нацрт непроменет и пријавете
invalid_response. - Тајмаут или 5xx: повторете само мал број пати, а потоа вратете поправлив неуспех на интерфејсот.
- Несигурна формулација: барајте човечки преглед, прикажете ја оригиналната порака покрај нацртот и никогаш не претворајте нацрт во автоматска обврска.
Конечна контролна листа за проверка
- Активираниот план, документираната вредност на моделот и тековниот сервисен токен се конфигурирани.
- Минималното автентицирано барање успева кон точната крајна точка chat-completions.
- Ниту еден акредитив не се појавува во контрола на изворниот код, фикстури, дневници на апликацијата или одговори во прелистувачот.
- PHPUnit проверува успешно мапирање, ограничен повторен обид и неавтоматски повторлив неуспех на автентикација.
- Рутата за персоналот одбива неавтентицирани, не-POST, невалидни и барања со несовпаѓачки CSRF.
- Генерираниот одговор се зачувува само како
pending_review. - Екранот за преглед го escape-ира излезот и бара лице да го уреди или одобри пред испраќањето.
- Продукциското следење разликува неуспеси на автентикација, квота, транспорт, HTTP и невалиден одговор.
Најсилната функционалност тука не е течниот текст. Тоа е намерниот јаз меѓу предлогот и дејството. Со тесна API-граница, дефанзивно мапирање, искрени состојби на неуспех и состојба во базата само за преглед, ВИ може да го отстрани товарот на празната страница од обично сандаче без да го отстрани лицето чија проценка го штити односот со клиентот.