Skip to content

Commit cb2b378

Browse files
committed
feat(structured-output): add schema-driven response formatting
- Implement StructuredOutput helper class for JSON schema definitions - Add generateStructured() method to Chat endpoint - Support both array and class-based schema definitions - Enable automatic response validation and hydration - Include PHP attribute system for IDE-friendly schema creation - Added a new test suite
1 parent 55ef5c5 commit cb2b378

File tree

8 files changed

+510
-3
lines changed

8 files changed

+510
-3
lines changed

.github/workflows/test.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Run Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- 'release/*'
8+
pull_request:
9+
branches:
10+
- main
11+
12+
jobs:
13+
test:
14+
name: Test
15+
runs-on: ubuntu-latest
16+
17+
strategy:
18+
matrix:
19+
php-version: ['8.1', '8.2']
20+
dependency-version: ['latest', 'lowest']
21+
22+
steps:
23+
- name: Checkout code
24+
uses: actions/checkout@v4
25+
26+
- name: Set up PHP
27+
uses: shivammathur/setup-php@v2
28+
with:
29+
php-version: ${{ matrix.php-version }}
30+
tools: composer
31+
32+
- name: Get Composer Cache Directory
33+
id: composer-cache
34+
run: |
35+
echo "::set-output name=dir::$(composer config cache-files-dir)"
36+
37+
- name: Cache Composer dependencies
38+
uses: actions/cache@v3
39+
with:
40+
path: ${{ steps.composer-cache.outputs.dir }}
41+
key: ${{ runner.os }}-composer-${{ matrix.php-version }}-${{ matrix.dependency-version }}
42+
restore-keys: ${{ runner.os }}-composer-${{ matrix.php-version }}-
43+
44+
- name: Install dependencies
45+
run: |
46+
if [ "${{ matrix.dependency-version }}" == "lowest" ]; then
47+
composer update --prefer-lowest --prefer-stable --no-progress --no-suggest
48+
else
49+
composer install --no-progress --no-suggest
50+
fi
51+
52+
- name: Run tests
53+
run: composer test

README.md

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,78 @@ echo $config->modelSupportsStreaming($model) // true
180180
echo $config->modelSupportsFunctions($model) // false
181181
```
182182

183+
#### _Structured Output_
184+
185+
```php
186+
<?php
187+
188+
use GrokPHP\Client\GrokClient;
189+
use GrokPHP\Enums\Model;
190+
191+
// 1. Define schema once
192+
$jsonSchema = [
193+
"type" => "object",
194+
"properties" => [
195+
"title" => ["type" => "string"],
196+
"authors" => ["type" => "array", "items" => ["type" => "string"]],
197+
"publication_year" => ["type" => "integer"],
198+
"doi" => ["type" => "string"],
199+
"keywords" => ["type" => "array", "items" => ["type" => "string"]],
200+
"citation_count" => ["type" => "integer"]
201+
],
202+
"required" => ["title", "authors"]
203+
];
204+
205+
206+
// 2. Process documents
207+
$client = new GrokClient();
208+
209+
foreach ($researchPapers as $paperText) {
210+
$metadata = $client->chat()->generateStructured($paperText, $jsonSchema);
211+
212+
// 3. Directly store structured data
213+
$this->database->insertPaper(
214+
title: $metadata['title'],
215+
authors: $metadata['authors'],
216+
year: $metadata['publication_year'] ?? null,
217+
doi: $metadata['doi'] ?? '',
218+
keywords: $metadata['keywords'] ?? []
219+
);
220+
}
221+
```
222+
223+
#### _Structured Output (alt. option with PHP class)_
224+
225+
```php
226+
// Define your schema as a PHP class
227+
class ResearchPaper extends \GrokPHP\Utils\DataModel
228+
{
229+
#[SchemaProperty(type: 'string', description: 'Paper title')]
230+
public string $title;
231+
232+
#[SchemaProperty(type: 'array', description: 'List of authors')]
233+
public array $authors;
234+
235+
#[SchemaProperty(type: 'string', description: 'Abstract text')]
236+
public string $abstract;
237+
}
238+
239+
// ...then, in your application code
240+
$result = $client->chat()->generateStructured(
241+
"Extract research paper details",
242+
ResearchPaper::class
243+
);
244+
245+
// ...and finally, get typed properties
246+
echo $result->title;
247+
echo $result->authors[0];
248+
249+
```
250+
183251

184252
## Response Handling
185253

186-
### Chat/Completion Response Methods
254+
#### _Chat/Completion Response Methods_
187255

188256
```php
189257
$response->getContent(); // Get response content
@@ -194,7 +262,7 @@ $response->getModel(); // Get model used
194262
$response->getUsage(); // Get token usage statistics
195263
```
196264

197-
### Image Analysis Response Methods
265+
#### _Image Analysis Response Methods_
198266

199267
```php
200268
$response->getAnalysis(); // Get analysis text
@@ -203,6 +271,12 @@ $response->getMetadata(); // Get image metadata
203271
$response->getUsage(); // Get token usage
204272
```
205273

274+
#### _Embedding Response Methods_
275+
```php
276+
$response->getEmbeddings(); // Get embeddings
277+
$response->getUsage(); // Get token usage
278+
```
279+
206280
## Error Handling
207281

208282
```php

src/Client/GrokClient.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,24 @@ public function __construct(array $options = [])
6363
$this->config = new Config(array_merge($options, ['api_key' => $apiKey]));
6464
}
6565

66+
/**
67+
* Sets the current model to be used by the client.
68+
*
69+
* @param Model $model
70+
* @return self
71+
*/
6672
public function model(Model $model): self
6773
{
6874
$this->currentModel = $model;
6975
return $this;
7076
}
7177

78+
/**
79+
* Begin a conversation with Grok AI, optionally with a pre-existing chat history.
80+
*
81+
* @param array $history
82+
* @return Chat
83+
*/
7284
public function beginConvo(array $history = []): Chat
7385
{
7486
return $this->chat()->withHistory($history);

src/Endpoints/Chat.php

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
use GrokPHP\Enums\Model;
1212
use GrokPHP\Traits\HasApiOperations;
1313
use GrokPHP\Traits\ValidatesInput;
14+
use GrokPHP\Utils\DataModel;
1415
use GrokPHP\Utils\RequestBuilder;
1516
use GrokPHP\Utils\ResponseParser;
17+
use GrokPHP\Utils\StructuredOutput;
1618
use GuzzleHttp\Client;
1719

1820
/**
@@ -113,7 +115,7 @@ public function send(string $message, ?Params $params = null): ChatMessage
113115
*/
114116
public function generate(string|array $prompt, ?Params $params = null): ChatMessage
115117
{
116-
$messages = $this->formatPrompt($prompt);
118+
$messages = $this->formatPrompt(prompt: $prompt);
117119
$payload = $this->requestBuilder->buildChatRequest(
118120
$messages,
119121
$params?->toArray() ?? [],
@@ -128,6 +130,52 @@ public function generate(string|array $prompt, ?Params $params = null): ChatMess
128130
return $this->responseParser->parse($response, 'chat');
129131
}
130132

133+
/**
134+
* Generates a structured response using the specified JSON Schema.
135+
* The API response is automatically parsed into an associative array.
136+
*
137+
* @param string|array $prompt
138+
* @param array|string $jsonSchema The JSON Schema that constrains the output.
139+
* @param Params|null $params Additional parameters.
140+
* @return array|string Parsed JSON structure or raw text if parsing fails.
141+
* @throws GrokException
142+
*/
143+
public function generateStructured(string|array $prompt, array|string $jsonSchema, ?Params $params = null): array|string
144+
{
145+
if (is_string($jsonSchema) && class_exists($jsonSchema)) {
146+
if (!is_subclass_of($jsonSchema, DataModel::class)) {
147+
throw new GrokException('Invalid schema class');
148+
}
149+
$schema = $jsonSchema::schema();
150+
} else {
151+
$schema = $jsonSchema;
152+
}
153+
154+
$structuredOutput = new StructuredOutput($schema);
155+
$messages = $this->formatPrompt($prompt);
156+
$payload = $this->requestBuilder->buildChatRequest(
157+
$messages,
158+
$params?->toArray() ?? [],
159+
$this->model->value
160+
);
161+
162+
$payload['response_format'] = $structuredOutput->toArray();
163+
164+
$response = $this->client->post(self::CHAT_ENDPOINT, [
165+
'json' => $payload,
166+
'headers' => $this->requestBuilder->buildHeaders($this->config->getApiKey()),
167+
]);
168+
169+
$chatMessage = $this->responseParser->parse($response, 'chat');
170+
$decoded = json_decode($chatMessage->getContent(), true);
171+
172+
if (is_string($schema) && class_exists($schema)) {
173+
return (new $schema())->fromArray($decoded);
174+
}
175+
176+
return $decoded;
177+
}
178+
131179
/**
132180
* Sets the chat history.
133181
*

src/Utils/DataModel.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace GrokPHP\Utils;
6+
7+
use ReflectionClass;
8+
9+
/**
10+
* Abstract base class for data models
11+
*
12+
* This class serves as a foundation for creating data models in the application.
13+
* It provides common structure and functionality that all data models should inherit.
14+
*
15+
* @abstract DataModel
16+
* @package GrokPHP\Utils
17+
* @author Alvin Panford <[email protected]>
18+
*/
19+
abstract class DataModel
20+
{
21+
/**
22+
* Returns the schema definition for the data model.
23+
*
24+
* The schema defines the structure and validation rules for the model's data fields.
25+
*
26+
* @return array
27+
*/
28+
public static function schema(): array
29+
{
30+
$schema = [
31+
'type' => 'object',
32+
'properties' => [],
33+
'required' => []
34+
];
35+
36+
$reflection = new ReflectionClass(static::class);
37+
38+
foreach ($reflection->getProperties() as $property) {
39+
$attributes = $property->getAttributes(SchemaProperty::class);
40+
41+
if (!empty($attributes)) {
42+
$attr = $attributes[0]->newInstance();
43+
$schema['properties'][$property->name] = $attr->toArray();
44+
45+
if ($attr->required) {
46+
$schema['required'][] = $property->name;
47+
}
48+
}
49+
}
50+
51+
return $schema;
52+
}
53+
54+
/**
55+
* Creates a new instance of the model from an array of data.
56+
*
57+
* @param array $data
58+
* @return static
59+
*/
60+
public function fromArray(array $data): static
61+
{
62+
foreach ($data as $key => $value) {
63+
if (property_exists($this, $key)) {
64+
$this->{$key} = $value;
65+
}
66+
}
67+
return $this;
68+
}
69+
}

src/Utils/SchemaProperty.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace GrokPHP\Utils;
6+
7+
use Attribute;
8+
9+
/**
10+
* Attribute class for marking class properties as schema properties.
11+
* This attribute can only be applied to properties.
12+
*
13+
* @see \Attribute
14+
* @package GrokPHP\Utils
15+
* @author Alvin Panford <[email protected]>
16+
*/
17+
#[Attribute(Attribute::TARGET_PROPERTY)]
18+
19+
class SchemaProperty
20+
{
21+
/**
22+
* Constructs a new instance of the SchemaProperty class.
23+
*
24+
* @param mixed ...$params
25+
* @return void
26+
*/
27+
public function __construct(
28+
public string $type = 'string',
29+
public bool $required = true,
30+
public ?string $description = null
31+
) {}
32+
33+
/**
34+
* Converts the SchemaProperty object to an associative array.
35+
*
36+
* @return array
37+
*/
38+
public function toArray(): array
39+
{
40+
return array_filter([
41+
'type' => $this->type,
42+
'description' => $this->description
43+
]);
44+
}
45+
}

0 commit comments

Comments
 (0)