Skip to content

Commit 84f2bca

Browse files
Merge pull request #12 from Guiziweb/refactor/decompose-grid-query-processor
refactor(processor): split GridQueryProcessor into resolver and validators
2 parents 87158b2 + 5b1984b commit 84f2bca

16 files changed

Lines changed: 405 additions & 245 deletions

config/services.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ services:
1212
Symfony\UX\Autocomplete\AutocompleterRegistry:
1313
alias: 'ux.autocomplete.autocompleter_registry'
1414

15-
Guiziweb\SyliusGridAssistantPlugin\Processor\GridQueryProcessor:
15+
Guiziweb\SyliusGridAssistantPlugin\Resolver\GridQueryResolver:
1616
arguments:
1717
$model: '%guiziweb.grid_assistant.model%'
1818

src/Processor/GridQueryProcessor.php

Lines changed: 24 additions & 183 deletions
Original file line numberDiff line numberDiff line change
@@ -4,153 +4,74 @@
44

55
namespace Guiziweb\SyliusGridAssistantPlugin\Processor;
66

7-
use Guiziweb\SyliusGridAssistantPlugin\Schema\Formatter\FilterValueFormatterRegistryInterface;
7+
use Guiziweb\SyliusGridAssistantPlugin\Resolver\GridQueryResolverException;
8+
use Guiziweb\SyliusGridAssistantPlugin\Resolver\GridQueryResolverInterface;
89
use Guiziweb\SyliusGridAssistantPlugin\Schema\GridSchemaBuilderInterface;
9-
use Guiziweb\SyliusGridAssistantPlugin\Toolbox\GridToolSchemaFactoryInterface;
10-
use Psr\Clock\ClockInterface;
10+
use Guiziweb\SyliusGridAssistantPlugin\Validator\GridCriteriaValidatorInterface;
11+
use Guiziweb\SyliusGridAssistantPlugin\Validator\GridSortingValidatorInterface;
1112
use Psr\Log\LoggerInterface;
12-
use Sylius\Component\Grid\Definition\Grid;
1313
use Sylius\Component\Grid\Provider\GridProviderInterface;
14-
use Symfony\AI\Platform\Message\Message;
15-
use Symfony\AI\Platform\Message\MessageBag;
16-
use Symfony\AI\Platform\PlatformInterface;
17-
use Symfony\AI\Platform\Result\ObjectResult;
18-
use Symfony\AI\Platform\Result\TextResult;
1914
use Symfony\Bundle\SecurityBundle\Security;
2015
use Symfony\Component\DependencyInjection\Attribute\Autowire;
2116
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
2217
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
2318
use Symfony\Contracts\Translation\TranslatorInterface;
2419

25-
final readonly class GridQueryProcessor
20+
final readonly class GridQueryProcessor implements GridQueryProcessorInterface
2621
{
27-
/**
28-
* @param non-empty-string $model
29-
*/
3022
public function __construct(
31-
private PlatformInterface $platform,
32-
private string $model,
23+
private GridQueryResolverInterface $queryResolver,
24+
private GridCriteriaValidatorInterface $criteriaValidator,
25+
private GridSortingValidatorInterface $sortingValidator,
3326
private GridSchemaBuilderInterface $schemaBuilder,
34-
private GridToolSchemaFactoryInterface $schemaFactory,
35-
private FilterValueFormatterRegistryInterface $formatterRegistry,
3627
private UrlGeneratorInterface $urlGenerator,
3728
private LoggerInterface $aiLogger,
3829
private RateLimiterFactoryInterface $aiQueryLimiter,
3930
private Security $security,
4031
private TranslatorInterface $translator,
41-
private ClockInterface $clock,
4232
#[Autowire(service: 'sylius.grid.chain_provider')]
4333
private GridProviderInterface $gridProvider,
4434
) {
4535
}
4636

47-
/**
48-
* Process a natural language query and return a redirect URL with filters.
49-
*
50-
* @param array<string, mixed> $routeParams
51-
*
52-
* @return array{redirect_url: string}|array{error: string}
53-
*/
54-
public function process(string $query, string $gridCode, string $routeName, array $routeParams): array
37+
public function process(string $query, string $gridCode, string $routeName, array $routeParams): string
5538
{
5639
$user = $this->security->getUser();
5740
if (null === $user) {
58-
return ['error' => $this->translator->trans('guiziweb.grid_assistant.rate_limit_unauthenticated')];
41+
throw new GridQueryProcessorException($this->translator->trans('guiziweb.grid_assistant.rate_limit_unauthenticated'));
5942
}
6043

61-
$limiter = $this->aiQueryLimiter->create($user->getUserIdentifier());
62-
$limit = $limiter->consume();
44+
$limit = $this->aiQueryLimiter->create($user->getUserIdentifier())->consume();
6345
if (!$limit->isAccepted()) {
6446
$retryAfter = $limit->getRetryAfter()->getTimestamp() - time();
6547

66-
return ['error' => $this->translator->trans('guiziweb.grid_assistant.rate_limit_exceeded', ['%seconds%' => max(1, $retryAfter)])];
48+
throw new GridQueryProcessorException($this->translator->trans('guiziweb.grid_assistant.rate_limit_exceeded', ['%seconds%' => max(1, $retryAfter)]));
6749
}
6850

6951
if (!$this->schemaBuilder->gridExists($gridCode)) {
70-
return ['error' => sprintf('Grid "%s" not found.', $gridCode)];
52+
throw new GridQueryProcessorException($this->translator->trans('guiziweb.grid_assistant.grid_not_found', ['%grid_code%' => $gridCode]));
7153
}
7254

73-
$gridSchema = $this->schemaBuilder->buildSchema($gridCode);
74-
$parametersSchema = $this->schemaFactory->buildParameters($gridSchema);
75-
76-
$systemPrompt = sprintf(
77-
"You are a Sylius grid filtering assistant.\n\n" .
78-
"Rules:\n" .
79-
"- criteria: all filter fields are always present. Set to null any filter not explicitly mentioned by the user.\n" .
80-
"- sorting: all sorting fields are always present. Set to null any field the user did not explicitly ask to sort by.\n" .
81-
"- message: write a short natural language summary of what you applied, or explain why no filter could be applied if the query is too vague.\n" .
82-
'- Today is %s.',
83-
$this->clock->now()->format('Y-m-d'),
84-
);
85-
86-
$messages = new MessageBag(
87-
Message::forSystem($systemPrompt),
88-
Message::ofUser(sprintf('User request: "%s"', $query)),
89-
);
90-
91-
$responseFormat = [
92-
'type' => 'json_schema',
93-
'json_schema' => [
94-
'name' => 'grid_filter',
95-
'schema' => $parametersSchema,
96-
'strict' => true,
97-
],
98-
];
99-
100-
$this->aiLogger->info('[GridAssistant] Processing query', [
101-
'query' => $query,
102-
'gridCode' => $gridCode,
103-
'routeName' => $routeName,
104-
]);
105-
10655
try {
107-
$deferred = $this->platform->invoke($this->model, $messages, ['response_format' => $responseFormat]);
108-
$result = $deferred->getResult();
109-
110-
if ($result instanceof ObjectResult) {
111-
/** @var array{criteria?: array<string, mixed>, sorting?: array<string, mixed>, message?: string|null}|null $data */
112-
$data = is_array($result->getContent()) ? $result->getContent() : null;
113-
} elseif ($result instanceof TextResult) {
114-
/** @var array{criteria?: array<string, mixed>, sorting?: array<string, mixed>, message?: string|null}|null $data */
115-
$data = json_decode($result->getContent(), true);
116-
} else {
117-
$data = null;
118-
}
119-
120-
$this->aiLogger->info('[GridAssistant] LLM response', ['data' => $data]);
121-
} catch (\Throwable $e) {
122-
$this->aiLogger->error('[GridAssistant] Platform error', [
123-
'error' => $e->getMessage(),
124-
'trace' => $e->getTraceAsString(),
125-
]);
126-
127-
return ['error' => sprintf('AI processing failed: %s', $e->getMessage())];
128-
}
56+
$resolved = $this->queryResolver->resolve($query, $gridCode);
57+
} catch (GridQueryResolverException $e) {
58+
$this->aiLogger->warning('[GridAssistant] Resolver error', ['error' => $e->getMessage()]);
12959

130-
if (!is_array($data)) {
131-
return ['error' => 'Could not parse AI response. Please try rephrasing.'];
60+
throw new GridQueryProcessorException($this->translator->trans('guiziweb.grid_assistant.ai_processing_failed'), 0, $e);
13261
}
13362

134-
$criteria = is_array($data['criteria'] ?? null) ? $data['criteria'] : [];
135-
$sorting = is_array($data['sorting'] ?? null) ? $data['sorting'] : [];
136-
$message = is_string($data['message'] ?? null) ? $data['message'] : null;
137-
138-
try {
139-
$grid = $this->gridProvider->get($gridCode);
140-
} catch (\InvalidArgumentException) {
141-
return ['error' => sprintf('Grid "%s" not found.', $gridCode)];
142-
}
63+
$grid = $this->gridProvider->get($gridCode);
14364

144-
$validCriteria = $this->validateAndFormatCriteria($criteria, $grid);
145-
$validSorting = $this->validateSorting($sorting, $grid);
65+
$validCriteria = $this->criteriaValidator->validate($resolved->criteria, $grid);
66+
$validSorting = $this->sortingValidator->validate($resolved->sorting, $grid);
14667

14768
$this->aiLogger->info('[GridAssistant] Applying filters', [
14869
'validCriteria' => $validCriteria,
14970
'validSorting' => $validSorting,
15071
]);
15172

15273
if (empty($validCriteria) && empty($validSorting)) {
153-
return ['error' => $message ?? 'Could not determine any filter from your query. Please try rephrasing.'];
74+
throw new GridQueryProcessorException($resolved->message ?? $this->translator->trans('guiziweb.grid_assistant.no_filter_determined'));
15475
}
15576

15677
$urlParams = $routeParams;
@@ -162,91 +83,11 @@ public function process(string $query, string $gridCode, string $routeName, arra
16283
}
16384

16485
try {
165-
$url = $this->urlGenerator->generate($routeName, $urlParams);
86+
return $this->urlGenerator->generate($routeName, $urlParams);
16687
} catch (\Exception $e) {
167-
return ['error' => sprintf('Failed to generate URL: %s', $e->getMessage())];
168-
}
169-
170-
return ['redirect_url' => $url];
171-
}
172-
173-
/**
174-
* @param array<string, mixed> $criteria
175-
*
176-
* @return array<string, mixed>
177-
*/
178-
private function validateAndFormatCriteria(array $criteria, Grid $grid): array
179-
{
180-
$validCriteria = [];
181-
182-
foreach ($criteria as $filterName => $value) {
183-
if (null === $value) {
184-
continue;
185-
}
186-
187-
if (!$grid->hasFilter($filterName)) {
188-
$this->aiLogger->warning('[GridAssistant] Unknown filter skipped', ['filter' => $filterName]);
189-
190-
continue;
191-
}
192-
193-
$filter = $grid->getFilter($filterName);
194-
$filterType = $filter->getType();
88+
$this->aiLogger->warning('[GridAssistant] URL generation failed', ['error' => $e->getMessage()]);
19589

196-
if ($this->formatterRegistry->has($filterType)) {
197-
$result = $this->formatterRegistry->get($filterType)->format($value, $filter);
198-
$formatted = $result->value;
199-
} else {
200-
$formatted = $value;
201-
}
202-
203-
if (null !== $formatted) {
204-
$validCriteria[$filterName] = $formatted;
205-
}
206-
}
207-
208-
return $validCriteria;
209-
}
210-
211-
/**
212-
* @param array<string, mixed> $sorting
213-
*
214-
* @return array<string, string>
215-
*/
216-
private function validateSorting(array $sorting, Grid $grid): array
217-
{
218-
$validSorting = [];
219-
$sortableFields = [];
220-
221-
foreach ($grid->getFields() as $field) {
222-
if ($field->isSortable()) {
223-
$sortableFields[] = $field->getName();
224-
}
90+
throw new GridQueryProcessorException($this->translator->trans('guiziweb.grid_assistant.url_generation_failed'), 0, $e);
22591
}
226-
227-
foreach ($sorting as $field => $direction) {
228-
if (null === $direction) {
229-
continue;
230-
}
231-
232-
if (!in_array($field, $sortableFields, true)) {
233-
$this->aiLogger->warning('[GridAssistant] Unknown sortable field skipped', ['field' => $field]);
234-
235-
continue;
236-
}
237-
238-
if (!is_string($direction)) {
239-
continue;
240-
}
241-
242-
$normalizedDirection = strtolower(trim($direction));
243-
if (!in_array($normalizedDirection, ['asc', 'desc'], true)) {
244-
$normalizedDirection = 'asc';
245-
}
246-
247-
$validSorting[$field] = $normalizedDirection;
248-
}
249-
250-
return $validSorting;
25192
}
25293
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Guiziweb\SyliusGridAssistantPlugin\Processor;
6+
7+
final class GridQueryProcessorException extends \RuntimeException
8+
{
9+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Guiziweb\SyliusGridAssistantPlugin\Processor;
6+
7+
interface GridQueryProcessorInterface
8+
{
9+
/**
10+
* Process a natural language query and return a redirect URL with filters applied.
11+
*
12+
* @param array<string, mixed> $routeParams
13+
*
14+
* @throws GridQueryProcessorException if the query cannot be processed (rate-limited, no filter determined, missing grid, etc.)
15+
*/
16+
public function process(string $query, string $gridCode, string $routeName, array $routeParams): string;
17+
}

0 commit comments

Comments
 (0)