Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/components/platform.rst
Original file line number Diff line number Diff line change
Expand Up @@ -769,12 +769,42 @@ To enable validation, register the ``ValidatorSubscriber`` with your platform's
The ``ValidatorSubscriber`` will automatically validate any :class:`Symfony\\AI\\Platform\\Result\\ObjectResult` produced
by the ``PlatformSubscriber``. To use this feature, make sure `symfony/validator` is installed in your project.

Parsing Partial JSON from Streams
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When consuming structured output as a stream, every delta only contains a fragment of the final JSON payload. To
render incremental UI updates (e.g. progressively filling a form, showing a partial list of items, etc.) you need a
parser that can recover the largest valid structure from an incomplete payload. The
``Symfony\AI\Platform\StructuredOutput\Streaming\PartialJsonParser`` provides exactly that.

The parser first attempts a strict ``json_decode`` and, if that fails, applies best-effort fixes in order:
trailing commas, unclosed strings, dangling colons, partial ``true``/``false``/``null`` literals, and unclosed
``{`` / ``[`` structures::

use Symfony\AI\Platform\StructuredOutput\Streaming\PartialJsonParser;

$buffer = '';

foreach ($chunks as $chunk) {
$buffer .= $chunk;

$partial = PartialJsonParser::parse($buffer, $errorMessage);

if (null !== $partial) {
// render the partial structure (array/object/scalar)
}
}

The method is ``static``, stateless, and dependency-free. It returns ``null`` and sets ``$errorMessage`` to the
``json_last_error_msg()`` text only when the input is unrecoverable. On success ``$errorMessage`` is reset to ``null``.

Code Examples
~~~~~~~~~~~~~

* `Structured Output with PHP class`_
* `Structured Output with array`_
* `Populating existing objects`_
* `Partial JSON parsing for streaming output`_

Server Tools
------------
Expand Down Expand Up @@ -1047,6 +1077,7 @@ Code Examples
.. _`Structured Output with PHP class`: https://github.com/symfony/ai/blob/main/examples/openai/structured-output-math.php
.. _`Structured Output with array`: https://github.com/symfony/ai/blob/main/examples/openai/structured-output-clock.php
.. _`Populating existing objects`: https://github.com/symfony/ai/blob/main/examples/platform/structured-output-populate-object.php
.. _`Partial JSON parsing for streaming output`: https://github.com/symfony/ai/blob/main/examples/platform/partial-json-parser.php
.. _`Parallel GPT Calls`: https://github.com/symfony/ai/blob/main/examples/misc/parallel-chat-gpt.php
.. _`Parallel Embeddings Calls`: https://github.com/symfony/ai/blob/main/examples/misc/parallel-embeddings.php
.. _`LM Studio`: https://lmstudio.ai/
Expand Down
43 changes: 43 additions & 0 deletions examples/platform/partial-json-parser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

use Symfony\AI\Platform\StructuredOutput\Streaming\PartialJsonParser;

require_once dirname(__DIR__).'/bootstrap.php';

// Simulates the progressive deltas a chat completion would emit when producing
// structured JSON output. Each chunk is appended to a running buffer and we try
// to render the largest valid structure we can recover so far.
$chunks = [
'{"title": "Symfony AI", ',
'"tags": ["php", "llm",',
' "agents"], "author": {"name": "Fa',
'bien", "twitter": "@fabpot"',
'}, "released": tru',
'e}',
];

$buffer = '';

foreach ($chunks as $index => $chunk) {
$buffer .= $chunk;

$partial = PartialJsonParser::parse($buffer, $error);

echo sprintf("Chunk %d: %s\n", $index + 1, $chunk);
echo 'Buffer: '.$buffer."\n";

if (null !== $partial) {
echo 'Parsed: '.json_encode($partial, \JSON_PRETTY_PRINT)."\n\n";
} else {
echo 'Unrecoverable: '.$error."\n\n";
}
}
1 change: 1 addition & 0 deletions src/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
0.9
---

* Add `PartialJsonParser` for recovering partial JSON from streaming output
* Add `ValidatorSubscriber` to validate structured output using Symfony Validator
* Add support for multiple system messages in `MessageBag`
* [BC BREAK] Rework `AssistantMessage` to hold `ContentInterface` parts (variadic constructor) instead of a single string content plus separate tool-call/thinking fields. Adds `Message\Content\Thinking`, `Message\Content\ExecutableCode`, and `Message\Content\CodeExecution` content classes, and makes `Result\ToolCall` implement `ContentInterface`. `Message::ofAssistant()` accepts strings, `ContentInterface`, and `ResultInterface` values, mapping `TextResult`/`ThinkingResult`/`ToolCallResult`/`ExecutableCodeResult`/`CodeExecutionResult`/`MultiPartResult` to their content equivalents; result types without a known mapping throw `InvalidArgumentException` so unhandled cases surface instead of being silently dropped.
Expand Down
169 changes: 169 additions & 0 deletions src/platform/src/StructuredOutput/Streaming/PartialJsonParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\AI\Platform\StructuredOutput\Streaming;

/**
* Best-effort parser for incomplete JSON strings produced by streaming structured output.
*
* Attempts a strict `json_decode` first and falls back to a sequence of recovery passes
* (trailing commas, unclosed strings, partial literals, unclosed structures) so callers
* can render partial objects before a model finishes emitting them.
*
* Byte-indexed scanning is intentional: `"` and `\` are always single-byte in UTF-8,
* so the string/escape tracking remains correct even for multi-byte payloads.
*
* @author Johannes Wachter <johannes@sulu.io>
*/
final class PartialJsonParser
{
/**
* @param-out string|null $errorMessage null on success, json_last_error_msg() text on failure
*/
public static function parse(string $json, ?string &$errorMessage = null): mixed
{
$data = @json_decode($json, true);

if (\JSON_ERROR_NONE === json_last_error()) {
$errorMessage = null;

return $data;
}

$errorMessage = json_last_error_msg();

return self::fixAndParse($json, $errorMessage);
}

private static function fixAndParse(string $json, ?string &$errorMessage): mixed
{
$fixed = self::fixSyntax($json);

$data = @json_decode($fixed, true);

if (\JSON_ERROR_NONE === json_last_error()) {
$errorMessage = null;

return $data;
}

$errorMessage = json_last_error_msg();

return null;
}

private static function fixSyntax(string $json): string
{
$json = preg_replace('/,\s*([\]}])/', '$1', $json) ?? $json;

$json = self::closeUnclosedStrings($json);
$json = self::fixIncompleteValues($json);

return self::closeUnclosedStructures($json);
}

private static function closeUnclosedStrings(string $json): string
{
$inString = false;
$escaped = false;
$length = \strlen($json);

for ($i = 0; $i < $length; ++$i) {
$char = $json[$i];

if ('"' === $char && !$escaped) {
$inString = !$inString;
}

$escaped = '\\' === $char && !$escaped;
}

if ($inString) {
$json .= '"';
}

return $json;
}

private static function fixIncompleteValues(string $json): string
{
$trimmed = rtrim($json);

if (preg_match('/:\s*$/', $trimmed)) {
$json = $trimmed.'null';
}

$trimmed = rtrim($json);
if (preg_match('/([\s:,\[\{])tru$/i', $trimmed)) {
$json = substr($trimmed, 0, -3).'true';
} elseif (preg_match('/([\s:,\[\{])tr$/i', $trimmed)) {
$json = substr($trimmed, 0, -2).'true';
} elseif (preg_match('/([\s:,\[\{])fals$/i', $trimmed)) {
$json = substr($trimmed, 0, -4).'false';
} elseif (preg_match('/([\s:,\[\{])fal$/i', $trimmed)) {
$json = substr($trimmed, 0, -3).'false';
} elseif (preg_match('/([\s:,\[\{])fa$/i', $trimmed)) {
$json = substr($trimmed, 0, -2).'false';
} elseif (preg_match('/([\s:,\[\{])nul$/i', $trimmed)) {
$json = substr($trimmed, 0, -3).'null';
} elseif (preg_match('/([\s:,\[\{])nu$/i', $trimmed)) {
$json = substr($trimmed, 0, -2).'null';
}

$trimmed = rtrim($json);
if (preg_match('/,\s*$/', $trimmed)) {
$json = preg_replace('/,\s*$/', '', $trimmed) ?? $trimmed;
}

return $json;
}

private static function closeUnclosedStructures(string $json): string
{
$stack = [];
$inString = false;
$escaped = false;
$length = \strlen($json);

for ($i = 0; $i < $length; ++$i) {
$char = $json[$i];

if ('"' === $char && !$escaped) {
$inString = !$inString;
}

$escaped = '\\' === $char && !$escaped;

if ($inString) {
continue;
}

if ('[' === $char || '{' === $char) {
$stack[] = $char;
} elseif (']' === $char) {
if ([] !== $stack && '[' === $stack[\count($stack) - 1]) {
array_pop($stack);
}
} elseif ('}' === $char) {
if ([] !== $stack && '{' === $stack[\count($stack) - 1]) {
array_pop($stack);
}
}
}

while ([] !== $stack) {
$open = array_pop($stack);
$json .= '[' === $open ? ']' : '}';
}

return $json;
}
}
Loading