fix shop research
This commit is contained in:
@@ -39,7 +39,7 @@ final readonly class AgentRunner
|
||||
$this->systemMsgOn = true;
|
||||
}
|
||||
|
||||
public function run(string $prompt, string $userId, bool $forceFullContext = false): Generator
|
||||
public function run(string $prompt, string $userId, bool $forceFullContext = false, string $requestContextHint = ''): Generator
|
||||
{
|
||||
$prompt = trim($prompt);
|
||||
|
||||
@@ -109,7 +109,7 @@ final readonly class AgentRunner
|
||||
if ($this->isCommerceIntent($commerceIntent)) {
|
||||
yield $this->systemMsg($this->agentRunnerConfig->getOptimizeSearchMessage(), 'think');
|
||||
|
||||
$commerceHistoryContext = $this->buildCommerceHistoryContext($userId);
|
||||
$commerceHistoryContext = $this->buildCommerceHistoryContext($userId, $requestContextHint);
|
||||
|
||||
if ($commerceHistoryContext !== '') {
|
||||
$this->addSource($sources, $this->agentRunnerConfig->getConversationHistorySourceLabel());
|
||||
@@ -136,6 +136,7 @@ final readonly class AgentRunner
|
||||
'optimizedShopQuery' => $optimizedShopQuery,
|
||||
'hasCommerceHistoryContext' => $commerceHistoryContext !== '',
|
||||
'commerceHistoryContextLength' => mb_strlen($commerceHistoryContext),
|
||||
'hasRequestContextHint' => trim($requestContextHint) !== '',
|
||||
]);
|
||||
|
||||
yield $this->systemMsg(
|
||||
@@ -925,12 +926,42 @@ final readonly class AgentRunner
|
||||
}
|
||||
}
|
||||
|
||||
private function buildCommerceHistoryContext(string $userId): string
|
||||
private function buildCommerceHistoryContext(string $userId, string $requestContextHint = ''): string
|
||||
{
|
||||
return $this->contextService->buildUserContextWithinBudget(
|
||||
$history = $this->contextService->buildUserContextWithinBudget(
|
||||
$userId,
|
||||
$this->agentRunnerConfig->getCommerceHistoryBudgetChars()
|
||||
);
|
||||
|
||||
$requestContextHint = $this->sanitizeRequestContextHintForCommerce($requestContextHint);
|
||||
|
||||
if ($requestContextHint === '') {
|
||||
return $history;
|
||||
}
|
||||
|
||||
if ($history === '') {
|
||||
return $requestContextHint;
|
||||
}
|
||||
|
||||
return trim($history) . "\n\n" . $requestContextHint;
|
||||
}
|
||||
|
||||
private function sanitizeRequestContextHintForCommerce(string $requestContextHint): string
|
||||
{
|
||||
$requestContextHint = str_replace(["\r\n", "\r"], "\n", $requestContextHint);
|
||||
$requestContextHint = preg_replace('/[\t ]+/u', ' ', $requestContextHint) ?? $requestContextHint;
|
||||
$requestContextHint = preg_replace('/\n{3,}/u', "\n\n", $requestContextHint) ?? $requestContextHint;
|
||||
$requestContextHint = trim($requestContextHint);
|
||||
|
||||
if ($requestContextHint === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (mb_strlen($requestContextHint, 'UTF-8') > 4000) {
|
||||
$requestContextHint = mb_substr($requestContextHint, 0, 4000, 'UTF-8');
|
||||
}
|
||||
|
||||
return trim($requestContextHint);
|
||||
}
|
||||
|
||||
private function limitKnowledgeChunks(array $knowledgeChunks, string $commerceIntent): array
|
||||
|
||||
@@ -460,6 +460,10 @@ final class AgentRunnerConfig
|
||||
'welches',
|
||||
'welchem',
|
||||
'welchen',
|
||||
'ist',
|
||||
'sind',
|
||||
'gut',
|
||||
'geeignet',
|
||||
'was',
|
||||
'wie',
|
||||
'wo',
|
||||
@@ -482,7 +486,6 @@ final class AgentRunnerConfig
|
||||
'fuer',
|
||||
'messen',
|
||||
'gemessen',
|
||||
'messung',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ final readonly class AskSseController
|
||||
FILTER_VALIDATE_BOOL
|
||||
);
|
||||
|
||||
$requestContextHint = $this->sanitizeRequestContextHint((string) ($data['contextHint'] ?? ''));
|
||||
|
||||
$cookieResponse = new Response();
|
||||
$clientId = $this->clientIdResolver->resolve($request, $cookieResponse);
|
||||
|
||||
@@ -63,6 +65,7 @@ final readonly class AskSseController
|
||||
'prompt' => $prompt,
|
||||
'clientId' => $clientId,
|
||||
'includeFullContext' => $includeFullContext,
|
||||
'requestContextHint' => $requestContextHint,
|
||||
'createdAt' => $now,
|
||||
'updatedAt' => $now,
|
||||
]);
|
||||
@@ -83,19 +86,20 @@ final readonly class AskSseController
|
||||
}
|
||||
|
||||
#[Route('/ask-sse/{jobId}', name: 'ask_sse_job', methods: ['GET'], requirements: ['jobId' => '[a-f0-9]{48}'])]
|
||||
public function streamJob(string $jobId): StreamedResponse
|
||||
public function streamJob(Request $request, string $jobId): StreamedResponse
|
||||
{
|
||||
$lastEventId = $this->resolveLastEventId($request);
|
||||
|
||||
return new StreamedResponse(
|
||||
function () use ($jobId): void {
|
||||
function () use ($jobId, $lastEventId): void {
|
||||
$claimed = $this->claimJob($jobId);
|
||||
|
||||
if (($claimed['ok'] ?? false) !== true) {
|
||||
$this->prepareStreamRuntime();
|
||||
echo "retry: 15000\n\n";
|
||||
echo "retry: 30000\n\n";
|
||||
|
||||
if ($this->shouldSilentlyCloseDuplicateJobStream($claimed)) {
|
||||
$this->sendComment('duplicate-or-finished-stream');
|
||||
$this->sendEvent('done', '[DONE]');
|
||||
if ($this->canReplayOrTailClaimedJob($claimed)) {
|
||||
$this->streamStoredJobResponse($jobId, $lastEventId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,7 +116,8 @@ final readonly class AskSseController
|
||||
clientId: (string) ($job['clientId'] ?? ''),
|
||||
includeFullContext: (bool) ($job['includeFullContext'] ?? false),
|
||||
cookieResponse: null,
|
||||
jobId: $jobId
|
||||
jobId: $jobId,
|
||||
requestContextHint: is_string($job['requestContextHint'] ?? null) ? (string) $job['requestContextHint'] : ''
|
||||
);
|
||||
},
|
||||
Response::HTTP_OK,
|
||||
@@ -136,17 +141,20 @@ final readonly class AskSseController
|
||||
FILTER_VALIDATE_BOOL
|
||||
);
|
||||
|
||||
$requestContextHint = $this->sanitizeRequestContextHint((string) ($data['contextHint'] ?? ''));
|
||||
|
||||
$cookieResponse = new Response();
|
||||
$clientId = $this->clientIdResolver->resolve($request, $cookieResponse);
|
||||
|
||||
return new StreamedResponse(
|
||||
function () use ($prompt, $clientId, $cookieResponse, $includeFullContext): void {
|
||||
function () use ($prompt, $clientId, $cookieResponse, $includeFullContext, $requestContextHint): void {
|
||||
$this->streamAgentResponse(
|
||||
prompt: $prompt,
|
||||
clientId: $clientId,
|
||||
includeFullContext: $includeFullContext,
|
||||
cookieResponse: $cookieResponse,
|
||||
jobId: null
|
||||
jobId: null,
|
||||
requestContextHint: $requestContextHint
|
||||
);
|
||||
},
|
||||
Response::HTTP_OK,
|
||||
@@ -159,7 +167,8 @@ final readonly class AskSseController
|
||||
string $clientId,
|
||||
bool $includeFullContext,
|
||||
?Response $cookieResponse,
|
||||
?string $jobId = null
|
||||
?string $jobId = null,
|
||||
string $requestContextHint = ''
|
||||
): void {
|
||||
$this->prepareStreamRuntime();
|
||||
$this->registerStreamShutdownErrorHandler($jobId);
|
||||
@@ -181,7 +190,7 @@ final readonly class AskSseController
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($this->agentRunner->run($prompt, $clientId, $includeFullContext) as $chunk) {
|
||||
foreach ($this->agentRunner->run($prompt, $clientId, $includeFullContext, $requestContextHint) as $chunk) {
|
||||
if (connection_aborted() === 1) {
|
||||
$this->markJobStatus(
|
||||
$jobId,
|
||||
@@ -192,7 +201,8 @@ final readonly class AskSseController
|
||||
}
|
||||
|
||||
$chunk = str_replace(["\r\n", "\r"], "\n", $chunk);
|
||||
$this->sendData($chunk);
|
||||
$eventId = $this->appendJobOutput($jobId, $chunk);
|
||||
$this->sendData($chunk, $eventId);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$message = 'Stream abgebrochen: ' . $this->formatThrowableForClient($e);
|
||||
@@ -261,6 +271,24 @@ final readonly class AskSseController
|
||||
});
|
||||
}
|
||||
|
||||
private function sanitizeRequestContextHint(string $contextHint): string
|
||||
{
|
||||
$contextHint = str_replace(["\r\n", "\r"], "\n", $contextHint);
|
||||
$contextHint = preg_replace('/[\t ]+/u', ' ', $contextHint) ?? $contextHint;
|
||||
$contextHint = preg_replace('/\n{3,}/u', "\n\n", $contextHint) ?? $contextHint;
|
||||
$contextHint = trim($contextHint);
|
||||
|
||||
if ($contextHint === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (mb_strlen($contextHint, 'UTF-8') > 4000) {
|
||||
$contextHint = mb_substr($contextHint, 0, 4000, 'UTF-8');
|
||||
}
|
||||
|
||||
return trim($contextHint);
|
||||
}
|
||||
|
||||
private function formatThrowableForClient(\Throwable $e): string
|
||||
{
|
||||
$message = trim($e->getMessage());
|
||||
@@ -297,13 +325,17 @@ final readonly class AskSseController
|
||||
];
|
||||
}
|
||||
|
||||
private function sendData(string $data): void
|
||||
private function sendData(string $data, ?int $eventId = null): void
|
||||
{
|
||||
if ($data === '') {
|
||||
$this->sendComment('keepalive');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($eventId !== null && $eventId > 0) {
|
||||
echo 'id: ' . $eventId . "\n";
|
||||
}
|
||||
|
||||
$lines = explode("\n", $data);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
@@ -511,22 +543,239 @@ final readonly class AskSseController
|
||||
}
|
||||
}
|
||||
}
|
||||
private function resolveLastEventId(Request $request): int
|
||||
{
|
||||
$header = trim((string) $request->headers->get('Last-Event-ID', ''));
|
||||
|
||||
if ($header === '' || !ctype_digit($header)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, (int) $header);
|
||||
}
|
||||
|
||||
/**
|
||||
* EventSource may reconnect to an already running or already completed job.
|
||||
* Those duplicate connections should be closed quietly so the UI does not
|
||||
* append a misleading error after the real stream already produced output.
|
||||
*
|
||||
* @param array<string, mixed> $claim
|
||||
*/
|
||||
private function shouldSilentlyCloseDuplicateJobStream(array $claim): bool
|
||||
private function canReplayOrTailClaimedJob(array $claim): bool
|
||||
{
|
||||
if (($claim['reason'] ?? null) !== 'not_pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = (string) ($claim['status'] ?? '');
|
||||
return in_array(
|
||||
(string) ($claim['status'] ?? ''),
|
||||
[
|
||||
self::JOB_STATUS_RUNNING,
|
||||
self::JOB_STATUS_COMPLETED,
|
||||
self::JOB_STATUS_INTERRUPTED,
|
||||
self::JOB_STATUS_FAILED,
|
||||
],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return $status === self::JOB_STATUS_COMPLETED;
|
||||
private function streamStoredJobResponse(string $jobId, int $lastEventId): void
|
||||
{
|
||||
$afterEventId = max(0, $lastEventId);
|
||||
$lastKeepaliveAt = 0;
|
||||
|
||||
while (true) {
|
||||
if (connection_aborted() === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->readJobOutputAfter($jobId, $afterEventId) as $event) {
|
||||
$eventId = (int) ($event['id'] ?? 0);
|
||||
$data = is_string($event['data'] ?? null) ? (string) $event['data'] : '';
|
||||
|
||||
if ($eventId <= $afterEventId || $data === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->sendData($data, $eventId);
|
||||
$afterEventId = $eventId;
|
||||
}
|
||||
|
||||
$job = $this->readJob($jobId);
|
||||
$status = is_array($job) ? (string) ($job['status'] ?? '') : '';
|
||||
$message = is_array($job) && is_string($job['message'] ?? null)
|
||||
? trim((string) $job['message'])
|
||||
: '';
|
||||
|
||||
if ($status === self::JOB_STATUS_COMPLETED) {
|
||||
$this->sendComment('replayed-completed-stream');
|
||||
$this->sendEvent('done', '[DONE]');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status === self::JOB_STATUS_FAILED) {
|
||||
$this->sendEvent(
|
||||
'error',
|
||||
$message !== ''
|
||||
? 'Der Antwort-Stream ist fehlgeschlagen: ' . $message
|
||||
: 'Der Antwort-Stream ist fehlgeschlagen. Bitte sende die Anfrage erneut.'
|
||||
);
|
||||
$this->sendEvent('done', '[DONE]');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status === self::JOB_STATUS_INTERRUPTED) {
|
||||
$this->sendEvent(
|
||||
'error',
|
||||
$message !== ''
|
||||
? $message
|
||||
: 'Der Antwort-Stream wurde durch einen Verbindungsabbruch unterbrochen. Bitte sende die Anfrage erneut, falls die Antwort unvollständig ist.'
|
||||
);
|
||||
$this->sendEvent('done', '[DONE]');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status !== self::JOB_STATUS_RUNNING) {
|
||||
$this->sendEvent('error', $this->jobClaimErrorMessage([
|
||||
'reason' => 'not_pending',
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
]));
|
||||
$this->sendEvent('done', '[DONE]');
|
||||
return;
|
||||
}
|
||||
|
||||
if (time() - $lastKeepaliveAt >= 10) {
|
||||
$this->sendComment('waiting-for-running-stream');
|
||||
$lastKeepaliveAt = time();
|
||||
}
|
||||
|
||||
usleep(250000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, data: string}>
|
||||
*/
|
||||
private function readJobOutputAfter(string $jobId, int $afterEventId): array
|
||||
{
|
||||
$path = $this->jobOutputPath($jobId);
|
||||
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
|
||||
if (!is_array($lines)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$events = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (!is_string($line) || trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded = json_decode($line, true);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = (int) ($decoded['id'] ?? 0);
|
||||
$data = is_string($decoded['data'] ?? null) ? (string) $decoded['data'] : '';
|
||||
|
||||
if ($id > $afterEventId && $data !== '') {
|
||||
$events[] = ['id' => $id, 'data' => $data];
|
||||
}
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
private function appendJobOutput(?string $jobId, string $data): ?int
|
||||
{
|
||||
if ($jobId === null || $data === '' || !preg_match('/\A[a-f0-9]{48}\z/', $jobId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$eventId = null;
|
||||
|
||||
try {
|
||||
$this->mutateJobWithLock($jobId, function (?array $job) use (&$eventId): array {
|
||||
if ($job === null) {
|
||||
return [
|
||||
'persist' => false,
|
||||
'result' => ['ok' => false],
|
||||
];
|
||||
}
|
||||
|
||||
$eventId = max(0, (int) ($job['lastEventId'] ?? 0)) + 1;
|
||||
$job['lastEventId'] = $eventId;
|
||||
$job['updatedAt'] = time();
|
||||
|
||||
return [
|
||||
'data' => $job,
|
||||
'result' => ['ok' => true],
|
||||
];
|
||||
});
|
||||
|
||||
if ($eventId === null || $eventId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$line = json_encode(
|
||||
['id' => $eventId, 'data' => $data],
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||
) . "\n";
|
||||
|
||||
if (file_put_contents($this->jobOutputPath($jobId), $line, FILE_APPEND | LOCK_EX) === false) {
|
||||
return null;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $eventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
$handle = @fopen($path, 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($handle, LOCK_SH)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = stream_get_contents($handle);
|
||||
flock($handle, LOCK_UN);
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
if (!is_string($content) || trim($content) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($content, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -590,7 +839,9 @@ final readonly class AskSseController
|
||||
$mtime = filemtime($path);
|
||||
|
||||
if ($mtime === false || $mtime < $threshold) {
|
||||
$base = preg_replace('/\.json\z/', '', $path) ?? $path;
|
||||
@unlink($path);
|
||||
@unlink($base . '.stream.ndjson');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,6 +851,11 @@ final readonly class AskSseController
|
||||
return $this->jobDirectory() . '/' . $jobId . '.json';
|
||||
}
|
||||
|
||||
private function jobOutputPath(string $jobId): string
|
||||
{
|
||||
return $this->jobDirectory() . '/' . $jobId . '.stream.ndjson';
|
||||
}
|
||||
|
||||
private function jobDirectory(): string
|
||||
{
|
||||
return rtrim($this->projectDir, '/\\') . '/var/stream_jobs';
|
||||
|
||||
Reference in New Issue
Block a user