Туториали

Symfony Smart Routing: AI-Powered FAQ Search for Your Customer Portal

Symfony Smart Routing: Пребарување ЧПП со вештачка интелигенција за вашиот кориснички портал

FAQ search looks simple until customers stop using the exact words in your documentation. They ask “Why did my card fail?” while the article says “payment authorization declined.” A useful helper must bridge that vocabulary gap without inventing policy, exposing credentials, or turning every request into an expensive, fragile AI call.

This tutorial builds a production-oriented FAQ endpoint for a Symfony customer portal. It searches a trusted local catalog, sends only the most relevant entries to the Smart Routing AI Model, and maps the OpenAI-style response into a small domain object. The design keeps retrieval deterministic and gives the model one constrained job: explain approved content clearly.

Get access before writing integration code

  1. Register at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
  2. Open the Smart Routing AI Model service page.
  3. Choose the available Free, Plus, or Pro plan and complete its activation.
  4. Open the official service documentation.
  5. Find the Service token panel and copy the service-scoped token shown there.

This service requires a token. Regenerating it revokes the previously active token, so coordinate rotation with deployment: install the new value everywhere that needs it before removing assumptions about the old credential. Never place the token in source control, fixtures, logs, screenshots, or browser-delivered JavaScript.

Confirm the endpoint with a minimal request

The exact API operation is POST https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions. Authentication uses Authorization: Bearer {serviceToken}. The request and response follow the OpenAI-compatible chat format; the service performs plan-based model routing, so this integration does not hard-code a provider-specific model.

export SMART_ROUTING_TOKEN="YOUR_SERVICE_TOKEN"

curl --fail-with-body \
  --request POST \
  --url "https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions" \
  --header "Authorization: Bearer ${SMART_ROUTING_TOKEN}" \
  --header "Content-Type: application/json" \
  --data '{
    "messages": [
      {
        "role": "user",
        "content": "Reply with one short sentence confirming that chat completion works."
      }
    ]
  }'

A successful response should contain an answer at choices[0].message.content. Inspect it manually, but do not paste the complete response into logs because generated content may reflect customer input.

Store the credential in Symfony’s uncommitted .env.local file for local development. Keep the non-secret endpoint in .env:

# .env
SMART_ROUTING_ENDPOINT=https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions

# .env.local — never commit this file
SMART_ROUTING_TOKEN=YOUR_SERVICE_TOKEN

Architecture: retrieve first, explain second

The portal exposes POST /portal/faq/search with a JSON body such as {"query":"How do I reset my password?"}. A local repository scores FAQ entries by matching normalized words. The three strongest entries become grounded context for the chat request. The model returns a concise explanation, while the API response also identifies the locally selected FAQ records.

This is deliberately synchronous. A customer waiting on search results benefits little from Messenger because the work must finish before the page can render. Messenger would add queues and polling without reducing perceived latency. If the feature later generates reports or processes bulk tickets, that separate workflow may justify background jobs.

The trade-off is that simple lexical retrieval can miss synonyms. It is transparent, fast, and adequate for a modest catalog; a larger knowledge base can replace only the repository with database full-text or vector retrieval while preserving the AI boundary and controller contract.

Create the Symfony project

composer create-project symfony/skeleton faq-portal
cd faq-portal
composer require symfony/http-client symfony/monolog-bundle
composer require --dev symfony/test-pack

The relevant structure is intentionally small:

src/
  Controller/FaqSearchController.php
  Faq/FaqRepository.php
  Faq/FaqAnswerService.php
  Integration/SmartRoutingClient.php
  Integration/SmartRoutingException.php
  Integration/SmartRoutingResult.php
tests/
  Integration/SmartRoutingClientTest.php
config/services.yaml

Bind the two scalar constructor arguments while allowing Symfony to autowire the HTTP client and logger:

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\Integration\SmartRoutingClient:
        arguments:
            $endpoint: '%env(SMART_ROUTING_ENDPOINT)%'
            $token: '%env(SMART_ROUTING_TOKEN)%'

Build a defensive API boundary

The integration owns authentication, timeouts, retries, JSON decoding, and response validation. Nothing outside it should know the provider’s response path.

<?php
// src/Integration/SmartRoutingResult.php
namespace App\Integration;

final readonly class SmartRoutingResult
{
    public function __construct(
        public string $answer,
        public ?string $responseId,
        public ?int $promptTokens,
        public ?int $completionTokens,
    ) {}
}

// src/Integration/SmartRoutingException.php
namespace App\Integration;

final class SmartRoutingException extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly int $publicStatus,
        string $message,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}
<?php
// src/Integration/SmartRoutingClient.php
namespace App\Integration;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class SmartRoutingClient
{
    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly LoggerInterface $logger,
        private readonly string $endpoint,
        private readonly string $token,
        private readonly ?\Closure $sleeper = null,
    ) {}

    public function complete(array $messages): SmartRoutingResult
    {
        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', $this->endpoint, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->token,
                        'Accept' => 'application/json',
                    ],
                    'json' => ['messages' => $messages],
                    'timeout' => 5.0,
                    'max_duration' => 15.0,
                ]);

                $status = $response->getStatusCode();

                if (($status === 429 || $status >= 500) && $attempt < 3) {
                    $this->logger->warning('Smart routing request will retry.', [
                        'attempt' => $attempt,
                        'status' => $status,
                    ]);
                    $this->pause($attempt);
                    continue;
                }

                if ($status === 401 || $status === 403) {
                    throw new SmartRoutingException(
                        'configuration_error',
                        503,
                        'The FAQ assistant is temporarily unavailable.'
                    );
                }

                if ($status === 429) {
                    throw new SmartRoutingException(
                        'rate_limited',
                        503,
                        'The FAQ assistant is busy. Please try again shortly.'
                    );
                }

                if ($status >= 400) {
                    throw new SmartRoutingException(
                        $status >= 500 ? 'upstream_unavailable' : 'request_rejected',
                        502,
                        'The FAQ assistant could not process the request.'
                    );
                }

                $data = json_decode(
                    $response->getContent(false),
                    true,
                    512,
                    JSON_THROW_ON_ERROR
                );

                $content = $data['choices'][0]['message']['content'] ?? null;
                if (!is_string($content) || trim($content) === '') {
                    throw new SmartRoutingException(
                        'invalid_response',
                        502,
                        'The FAQ assistant returned an invalid response.'
                    );
                }

                $usage = is_array($data['usage'] ?? null) ? $data['usage'] : [];

                return new SmartRoutingResult(
                    trim($content),
                    is_string($data['id'] ?? null) ? $data['id'] : null,
                    is_int($usage['prompt_tokens'] ?? null)
                        ? $usage['prompt_tokens'] : null,
                    is_int($usage['completion_tokens'] ?? null)
                        ? $usage['completion_tokens'] : null,
                );
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new SmartRoutingException(
                        'upstream_unavailable',
                        503,
                        'The FAQ assistant is temporarily unavailable.',
                        $exception
                    );
                }

                $this->logger->warning('Smart routing transport failure.', [
                    'attempt' => $attempt,
                    'exception' => $exception::class,
                ]);
                $this->pause($attempt);
            } catch (\JsonException $exception) {
                throw new SmartRoutingException(
                    'invalid_response',
                    502,
                    'The FAQ assistant returned an invalid response.',
                    $exception
                );
            }
        }

        throw new \LogicException('Retry loop ended unexpectedly.');
    }

    private function pause(int $attempt): void
    {
        $microseconds = $attempt === 1 ? 200_000 : 500_000;
        ($this->sleeper ?? static fn (int $delay) => usleep($delay))($microseconds);
    }
}

The retry policy is intentionally narrow. Transport failures, HTTP 429 responses, and server errors may be transient. Authentication and other client errors are not retried because repetition cannot repair a revoked token or invalid request. Three attempts and bounded delays prevent a single portal request from occupying a worker indefinitely.

Search trusted FAQs and construct grounded context

<?php
// src/Faq/FaqRepository.php
namespace App\Faq;

final class FaqRepository
{
    private array $items = [
        [
            'id' => 'password-reset',
            'question' => 'How do I reset my password?',
            'answer' => 'Use Forgot password on the sign-in page. The reset link expires after 30 minutes.',
        ],
        [
            'id' => 'payment-declined',
            'question' => 'Why was my card payment declined?',
            'answer' => 'Verify the card details and billing address, then ask the card issuer if the decline continues.',
        ],
        [
            'id' => 'invoice-download',
            'question' => 'Where can I download an invoice?',
            'answer' => 'Open Billing, choose the payment, and select Download invoice.',
        ],
    ];

    public function search(string $query, int $limit = 3): array
    {
        $terms = array_values(array_filter(
            preg_split('/[^a-z0-9]+/', strtolower($query)) ?: [],
            static fn (string $term) => strlen($term) > 2
        ));

        $scored = [];
        foreach ($this->items as $item) {
            $text = strtolower($item['question'].' '.$item['answer']);
            $score = array_sum(array_map(
                static fn (string $term) => str_contains($text, $term) ? 1 : 0,
                $terms
            ));

            if ($score > 0) {
                $scored[] = ['score' => $score, 'item' => $item];
            }
        }

        usort($scored, static fn (array $a, array $b) => $b['score'] <=> $a['score']);

        return array_column(array_slice($scored, 0, $limit), 'item');
    }
}
<?php
// src/Faq/FaqAnswerService.php
namespace App\Faq;

use App\Integration\SmartRoutingClient;

final readonly class FaqAnswerService
{
    public function __construct(
        private FaqRepository $faqs,
        private SmartRoutingClient $client,
    ) {}

    public function answer(string $query): array
    {
        $matches = $this->faqs->search($query);

        if ($matches === []) {
            return [
                'answer' => 'No matching FAQ was found. Please contact support.',
                'sources' => [],
                'response_id' => null,
            ];
        }

        $context = json_encode($matches, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
        $result = $this->client->complete([
            [
                'role' => 'system',
                'content' => 'Answer only from the supplied FAQ context. '
                    .'If it does not contain the answer, say that support is needed. '
                    .'Do not invent policies, links, prices, or account details.',
            ],
            [
                'role' => 'user',
                'content' => "FAQ context:\n".$context."\n\nCustomer question:\n".$query,
            ],
        ]);

        return [
            'answer' => $result->answer,
            'sources' => array_column($matches, 'id'),
            'response_id' => $result->responseId,
        ];
    }
}

The context is application data, not trusted instructions. The system message limits the answer, but prompts are not a security boundary. Never include secrets, private notes, or another customer’s records. The returned source identifiers describe the entries supplied to the model; they should not be presented as proof that every sentence was independently verified.

Expose the portal endpoint

<?php
// src/Controller/FaqSearchController.php
namespace App\Controller;

use App\Faq\FaqAnswerService;
use App\Integration\SmartRoutingException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final readonly class FaqSearchController
{
    public function __construct(private FaqAnswerService $answers) {}

    #[Route('/portal/faq/search', name: 'portal_faq_search', methods: ['POST'])]
    public function __invoke(Request $request): JsonResponse
    {
        try {
            $payload = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return new JsonResponse(['error' => [
                'code' => 'invalid_json',
                'message' => 'Send a valid JSON object.',
            ]], 400);
        }

        $query = is_string($payload['query'] ?? null)
            ? trim($payload['query']) : '';

        if ($query === '' || strlen($query) > 500) {
            return new JsonResponse(['error' => [
                'code' => 'invalid_query',
                'message' => 'Query must contain between 1 and 500 bytes.',
            ]], 422);
        }

        try {
            return new JsonResponse($this->answers->answer($query));
        } catch (SmartRoutingException $exception) {
            return new JsonResponse(['error' => [
                'code' => $exception->kind,
                'message' => $exception->getMessage(),
            ]], $exception->publicStatus);
        }
    }
}

In a real portal, place this route behind the existing Symfony firewall and apply per-user or per-IP rate limiting. The browser calls your Symfony endpoint, never the upstream service directly. Add CSRF protection if cookie-authenticated clients can invoke the route cross-origin or through a form flow, and retain the portal’s normal authorization checks.

Test without making network calls

MockHttpClient provides deterministic transport behavior. These tests verify response mapping and prove that authentication failures are not retried.

<?php
// tests/Integration/SmartRoutingClientTest.php
namespace App\Tests\Integration;

use App\Integration\SmartRoutingClient;
use App\Integration\SmartRoutingException;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class SmartRoutingClientTest extends TestCase
{
    public function testMapsAValidChatResponse(): void
    {
        $http = new MockHttpClient(new MockResponse(json_encode([
            'id' => 'response-test',
            'choices' => [[
                'message' => ['content' => 'Open Billing and download the invoice.'],
            ]],
            'usage' => ['prompt_tokens' => 20, 'completion_tokens' => 8],
        ], JSON_THROW_ON_ERROR), [
            'http_code' => 200,
            'response_headers' => ['content-type: application/json'],
        ]));

        $client = new SmartRoutingClient(
            $http,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions',
            'test-token',
            static fn (int $delay) => null,
        );

        $result = $client->complete([
            ['role' => 'user', 'content' => 'Where is my invoice?'],
        ]);

        self::assertSame('Open Billing and download the invoice.', $result->answer);
        self::assertSame('response-test', $result->responseId);
        self::assertSame(20, $result->promptTokens);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $http = new MockHttpClient(new MockResponse('', ['http_code' => 401]));
        $client = new SmartRoutingClient(
            $http,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/smart-routing-ai-model/v1/chat/completions',
            'revoked-token',
            static fn (int $delay) => null,
        );

        try {
            $client->complete([['role' => 'user', 'content' => 'Question']]);
            self::fail('Expected SmartRoutingException.');
        } catch (SmartRoutingException $exception) {
            self::assertSame('configuration_error', $exception->kind);
            self::assertSame(1, $http->getRequestsCount());
        }
    }
}
php bin/phpunit
symfony server:start

curl --request POST \
  --url "http://127.0.0.1:8000/portal/faq/search" \
  --header "Content-Type: application/json" \
  --data '{"query":"Where can I get my invoice?"}'

Operate it like a production dependency

Log status, attempt number, latency, structured failure kind, and the upstream response ID when available. Usage fields may help observe quota consumption, but treat them as optional because the boundary already validates uncertain data defensively. Never log authorization headers, full prompts, raw responses, or customer questions by default.

Publish counters for successes, 429 responses, upstream failures, invalid responses, and local no-match results. Alert on sustained error ratios rather than an isolated retry. A rising no-match rate usually indicates missing FAQ vocabulary or content, while authentication failures commonly mean the token was revoked, regenerated, or absent from the deployment environment.

At deployment, provide SMART_ROUTING_TOKEN through the platform’s secret manager and SMART_ROUTING_ENDPOINT through environment configuration. Warm Symfony’s production cache, run the test suite, and perform one authenticated smoke request. During token rotation, update every application instance and restart or redeploy processes that retain old environment values.

Common failures

  • 401 or 403: verify the service-scoped token, plan activation, environment name, and whether regeneration revoked the deployed value.
  • 429: the request is rate-limited or quota-constrained. Keep retries bounded, respect the selected plan, and show a recoverable portal message.
  • Repeated 5xx or transport errors: check outbound connectivity and DNS, then rely on the short fallback rather than holding PHP workers open.
  • Empty or malformed choices: treat the response as invalid; never index deeply without validation or display raw upstream JSON.
  • Plausible but unsupported answers: reduce the supplied context, strengthen grounding instructions, and make escalation explicit. Do not solve this by feeding the model private support history.

Final verification checklist

  • The account and Free, Plus, or Pro plan are active.
  • The service token exists only in environment-backed secret configuration.
  • The application posts to the exact /v1/chat/completions endpoint with Bearer authentication.
  • Only locally selected FAQ entries and the current question enter the prompt.
  • Connection work and total response duration are bounded.
  • Only transport failures, 429 responses, and server failures receive limited retries.
  • OpenAI-style response fields are validated before domain mapping.
  • Authentication, malformed response, quota, and no-match paths have safe user-facing states.
  • Tests use MockHttpClient and require no live token.
  • Logs and metrics reveal reliability problems without capturing credentials or customer content.

The durable part of this design is not the prompt. It is the boundary around it: trusted retrieval, minimal context, defensive mapping, bounded failure behavior, and an honest fallback. With those pieces in place, AI-powered FAQ search stops being a demo attached to a text box and becomes a maintainable feature that belongs in an everyday customer portal.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.