optimize stream sse handling
This commit is contained in:
@@ -102,6 +102,16 @@ final class ShopSearchService
|
||||
referenceContext: $referenceContext
|
||||
);
|
||||
|
||||
if ($this->lastSearchHadSystemFailure) {
|
||||
$this->logger->warning('Shop search stopped after Store API system failure during reference probe', [
|
||||
'commerceIntent' => $commerceIntent,
|
||||
'originalPrompt' => $originalPrompt,
|
||||
'failureReason' => $this->lastSearchFailureReason,
|
||||
]);
|
||||
|
||||
//return [];
|
||||
}
|
||||
|
||||
$rankedProducts = $this->executeSearch(
|
||||
$primaryQuery,
|
||||
$commerceIntent,
|
||||
@@ -109,7 +119,7 @@ final class ShopSearchService
|
||||
true
|
||||
);
|
||||
|
||||
if ($rankedProducts === [] && $commerceHistoryContext !== '') {
|
||||
if ($rankedProducts === [] && $commerceHistoryContext !== '' && !$this->lastSearchHadSystemFailure) {
|
||||
$fallbackQuery = $this->queryParser->parse(
|
||||
$originalPrompt,
|
||||
$commerceIntent,
|
||||
@@ -228,6 +238,17 @@ final class ShopSearchService
|
||||
false
|
||||
);
|
||||
|
||||
if ($this->lastSearchHadSystemFailure) {
|
||||
$this->logger->warning('Shop reference probe stopped after Store API system failure', [
|
||||
'commerceIntent' => $commerceIntent,
|
||||
'originalPrompt' => $originalPrompt,
|
||||
'referenceSearchText' => $referenceSearchText,
|
||||
'failureReason' => $this->lastSearchFailureReason,
|
||||
]);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if ($results !== []) {
|
||||
$allResults = array_merge($allResults, $results);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Agent\AgentRunner;
|
||||
use App\Http\ClientIdResolver;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
@@ -13,12 +14,91 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
final readonly class AskSseController
|
||||
{
|
||||
private const JOB_TTL_SECONDS = 30;
|
||||
|
||||
public function __construct(
|
||||
private AgentRunner $agentRunner,
|
||||
private ClientIdResolver $clientIdResolver,
|
||||
private string $projectDir,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/ask-jobs', name: 'ask_job_start', methods: ['POST'])]
|
||||
public function startJob(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$prompt = trim((string) ($data['prompt'] ?? ''));
|
||||
|
||||
if ($prompt === '') {
|
||||
return new JsonResponse(['error' => 'Empty prompt'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default:
|
||||
* - Browser chat uses budgeted recent context
|
||||
* - Full context must be explicitly requested
|
||||
*/
|
||||
$includeFullContext = filter_var(
|
||||
$data['fullContext'] ?? false,
|
||||
FILTER_VALIDATE_BOOL
|
||||
);
|
||||
|
||||
$cookieResponse = new Response();
|
||||
$clientId = $this->clientIdResolver->resolve($request, $cookieResponse);
|
||||
|
||||
try {
|
||||
$this->cleanupExpiredJobs();
|
||||
$jobId = bin2hex(random_bytes(24));
|
||||
$this->writeJob($jobId, [
|
||||
'prompt' => $prompt,
|
||||
'clientId' => $clientId,
|
||||
'includeFullContext' => $includeFullContext,
|
||||
'createdAt' => time(),
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
return new JsonResponse(
|
||||
['error' => 'Stream job could not be created.'],
|
||||
Response::HTTP_INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
$response = new JsonResponse(['jobId' => $jobId]);
|
||||
|
||||
foreach ($cookieResponse->headers->getCookies() as $cookie) {
|
||||
$response->headers->setCookie($cookie);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/ask-sse/{jobId}', name: 'ask_sse_job', methods: ['GET'], requirements: ['jobId' => '[a-f0-9]{48}'])]
|
||||
public function streamJob(string $jobId): StreamedResponse
|
||||
{
|
||||
return new StreamedResponse(
|
||||
function () use ($jobId): void {
|
||||
$job = $this->readJob($jobId);
|
||||
|
||||
if ($job === null) {
|
||||
$this->prepareStreamRuntime();
|
||||
echo "retry: 15000\n\n";
|
||||
$this->sendEvent('error', 'Der Antwort-Job ist abgelaufen oder wurde nicht gefunden. Bitte sende die Anfrage erneut.');
|
||||
$this->sendEvent('done', '[DONE]');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteJob($jobId);
|
||||
$this->streamAgentResponse(
|
||||
prompt: (string) ($job['prompt'] ?? ''),
|
||||
clientId: (string) ($job['clientId'] ?? ''),
|
||||
includeFullContext: (bool) ($job['includeFullContext'] ?? false),
|
||||
cookieResponse: null
|
||||
);
|
||||
},
|
||||
Response::HTTP_OK,
|
||||
$this->streamHeaders()
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/ask-sse', name: 'ask_sse', methods: ['POST'])]
|
||||
public function stream(Request $request): StreamedResponse
|
||||
{
|
||||
@@ -40,54 +120,83 @@ final readonly class AskSseController
|
||||
|
||||
return new StreamedResponse(
|
||||
function () use ($prompt, $clientId, $cookieResponse, $includeFullContext): void {
|
||||
@set_time_limit(0);
|
||||
@ini_set('output_buffering', 'off');
|
||||
@ini_set('zlib.output_compression', '0');
|
||||
$this->streamAgentResponse(
|
||||
prompt: $prompt,
|
||||
clientId: $clientId,
|
||||
includeFullContext: $includeFullContext,
|
||||
cookieResponse: $cookieResponse
|
||||
);
|
||||
},
|
||||
Response::HTTP_OK,
|
||||
$this->streamHeaders()
|
||||
);
|
||||
}
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
private function streamAgentResponse(
|
||||
string $prompt,
|
||||
string $clientId,
|
||||
bool $includeFullContext,
|
||||
?Response $cookieResponse
|
||||
): void {
|
||||
$this->prepareStreamRuntime();
|
||||
|
||||
foreach ($cookieResponse->headers->getCookies() as $cookie) {
|
||||
header('Set-Cookie: ' . $cookie, false);
|
||||
}
|
||||
if ($cookieResponse !== null) {
|
||||
foreach ($cookieResponse->headers->getCookies() as $cookie) {
|
||||
header('Set-Cookie: ' . $cookie, false);
|
||||
}
|
||||
}
|
||||
|
||||
echo "retry: 3000\n\n";
|
||||
$this->sendComment('stream-open');
|
||||
echo "retry: 15000\n\n";
|
||||
$this->sendComment('stream-open');
|
||||
|
||||
if ($prompt === '') {
|
||||
$this->sendEvent('error', 'Empty prompt');
|
||||
$this->sendEvent('done', '[DONE]');
|
||||
if ($prompt === '') {
|
||||
$this->sendEvent('error', 'Empty prompt');
|
||||
$this->sendEvent('done', '[DONE]');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($this->agentRunner->run($prompt, $clientId, $includeFullContext) as $chunk) {
|
||||
if (connection_aborted() === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($this->agentRunner->run($prompt, $clientId, $includeFullContext) as $chunk) {
|
||||
if (connection_aborted() === 1) {
|
||||
return;
|
||||
}
|
||||
$chunk = str_replace(["\r\n", "\r"], "\n", $chunk);
|
||||
$this->sendData($chunk);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->sendEvent(
|
||||
'error',
|
||||
'❌ Stream abgebrochen: ' . $e->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
$chunk = str_replace(["\r\n", "\r"], "\n", $chunk);
|
||||
$this->sendData($chunk);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->sendEvent(
|
||||
'error',
|
||||
'❌ Stream abgebrochen: ' . $e->getMessage()
|
||||
);
|
||||
}
|
||||
$this->sendEvent('done', '[DONE]');
|
||||
}
|
||||
|
||||
$this->sendEvent('done', '[DONE]');
|
||||
},
|
||||
200,
|
||||
[
|
||||
'Content-Type' => 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control' => 'no-cache, no-store, must-revalidate',
|
||||
'Connection' => 'keep-alive',
|
||||
'X-Accel-Buffering' => 'no',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]
|
||||
);
|
||||
private function prepareStreamRuntime(): void
|
||||
{
|
||||
@set_time_limit(0);
|
||||
@ini_set('output_buffering', 'off');
|
||||
@ini_set('zlib.output_compression', '0');
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function streamHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Content-Type' => 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control' => 'no-cache, no-store, must-revalidate, no-transform',
|
||||
'Connection' => 'keep-alive',
|
||||
'X-Accel-Buffering' => 'no',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
];
|
||||
}
|
||||
|
||||
private function sendData(string $data): void
|
||||
@@ -103,7 +212,7 @@ final readonly class AskSseController
|
||||
echo 'data: ' . $line . "\n";
|
||||
}
|
||||
|
||||
echo "\n\n";
|
||||
echo "\n";
|
||||
$this->flushOutput();
|
||||
}
|
||||
|
||||
@@ -132,4 +241,101 @@ final readonly class AskSseController
|
||||
|
||||
@flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function writeJob(string $jobId, array $payload): void
|
||||
{
|
||||
$directory = $this->jobDirectory();
|
||||
|
||||
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||
throw new \RuntimeException('Stream job directory could not be created.');
|
||||
}
|
||||
|
||||
$json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if (file_put_contents($this->jobPath($jobId), $json, LOCK_EX) === false) {
|
||||
throw new \RuntimeException('Stream job could not be written.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function readJob(string $jobId): ?array
|
||||
{
|
||||
if (!preg_match('/\A[a-f0-9]{48}\z/', $jobId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $this->jobPath($jobId);
|
||||
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
|
||||
if (!is_string($content) || $content === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$createdAt = (int) ($data['createdAt'] ?? 0);
|
||||
|
||||
if ($createdAt <= 0 || time() - $createdAt > self::JOB_TTL_SECONDS) {
|
||||
$this->deleteJob($jobId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function deleteJob(string $jobId): void
|
||||
{
|
||||
$path = $this->jobPath($jobId);
|
||||
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupExpiredJobs(): void
|
||||
{
|
||||
$directory = $this->jobDirectory();
|
||||
|
||||
if (!is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$threshold = time() - self::JOB_TTL_SECONDS;
|
||||
|
||||
foreach (glob($directory . '/*.json') ?: [] as $path) {
|
||||
if (!is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mtime = filemtime($path);
|
||||
|
||||
if ($mtime === false || $mtime < $threshold) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function jobPath(string $jobId): string
|
||||
{
|
||||
return $this->jobDirectory() . '/' . $jobId . '.json';
|
||||
}
|
||||
|
||||
private function jobDirectory(): string
|
||||
{
|
||||
return rtrim($this->projectDir, '/\\') . '/var/stream_jobs';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user