44
55namespace Guiziweb \SyliusGridAssistantPlugin \Processor ;
66
7- use Guiziweb \SyliusGridAssistantPlugin \Schema \Formatter \FilterValueFormatterRegistryInterface ;
7+ use Guiziweb \SyliusGridAssistantPlugin \Resolver \GridQueryResolverException ;
8+ use Guiziweb \SyliusGridAssistantPlugin \Resolver \GridQueryResolverInterface ;
89use 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 ;
1112use Psr \Log \LoggerInterface ;
12- use Sylius \Component \Grid \Definition \Grid ;
1313use 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 ;
1914use Symfony \Bundle \SecurityBundle \Security ;
2015use Symfony \Component \DependencyInjection \Attribute \Autowire ;
2116use Symfony \Component \RateLimiter \RateLimiterFactoryInterface ;
2217use Symfony \Component \Routing \Generator \UrlGeneratorInterface ;
2318use 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}
0 commit comments