diff --git a/Classes/Middleware/FlagAiContentMiddleware.php b/Classes/Middleware/FlagAiContentMiddleware.php new file mode 100644 index 0000000..b425f86 --- /dev/null +++ b/Classes/Middleware/FlagAiContentMiddleware.php @@ -0,0 +1,171 @@ +content afterwards), so this is opt-in per call: + * + * $ai->text()->prompt(...)->withMetadata([ + * 'aiLabel' => ['table' => 'sys_file_metadata', 'uid' => $uid, 'origin' => 'created'], + * ])->send(); + * + * 'origin' is 'created' (brand-new AI content, the default if omitted) or + * 'modified' (existing human content AI-edited), mirrors AiOrigin. Writing + * through AiLabelApi requires a backend user to attribute the change to + * (its own resolveUser()); calls made outside a backend context (frontend, + * CLI/scheduler) will fail that check, which this middleware treats as a + * non-fatal, logged no-op rather than surfacing it as an aim error. + * + * Only ever instantiated when b13/aim is actually installed: this class is + * excluded from Services.yaml's normal `resource: '../Classes/*'` scan, so + * Symfony's container compilation never reflects (and therefore never needs + * to resolve AiMiddlewareInterface for) it when aim isn't present. + * Configuration/Services.php registers and tags it manually instead, gated + * behind a runtime class_exists() check. + * + * Priority -850 there, chosen the same way as any other aim + * middleware: after CostTrackingMiddleware (-800) so the request is fully + * settled, before EventDispatchMiddleware (-900) / CoreDispatchMiddleware + * (-1000) so this only ever runs for genuinely successful responses. + */ +final class FlagAiContentMiddleware implements AiMiddlewareInterface +{ + public function __construct( + private readonly AiLabelApi $aiLabelApi, + private readonly LoggerInterface $logger, + ) { + } + + public function process( + AiRequestInterface $request, + AiProviderInterface $provider, + ProviderConfiguration $configuration, + AiMiddlewareHandler $next, + ): TextResponse { + $target = $this->resolveTarget($request); + if ($target === null) { + return $next->handle($request, $provider, $configuration); + } + + $response = $next->handle($request, $provider, $configuration); + + if (($response instanceof ConversationResponse || $response instanceof ToolCallingResponse) && $response->isStreaming()) { + $this->deferLabel($response, $target); + return $response; + } + + if ($response->isSuccessful()) { + $this->applyLabel($target); + } + + return $response; + } + + /** + * @return array{table: string, uid: int, origin: string}|null + */ + private function resolveTarget(AiRequestInterface $request): ?array + { + if (!property_exists($request, 'metadata') || !is_array($request->metadata)) { + return null; + } + + $raw = $request->metadata['aiLabel'] ?? null; + if (!is_array($raw) || !isset($raw['table'], $raw['uid'])) { + return null; + } + + $table = (string)$raw['table']; + $uid = (int)$raw['uid']; + if ($table === '' || $uid <= 0) { + return null; + } + + $origin = (string)($raw['origin'] ?? 'created'); + if (!in_array($origin, ['created', 'modified'], true)) { + $origin = 'created'; + } + + return ['table' => $table, 'uid' => $uid, 'origin' => $origin]; + } + + /** + * @param array{table: string, uid: int, origin: string} $target + */ + private function deferLabel(ConversationResponse|ToolCallingResponse $response, array $target): void + { + $streamIterator = $response->streamIterator; + if (!$streamIterator instanceof StreamChunkIterator) { + return; + } + + register_shutdown_function( + function () use ($streamIterator, $target): void { + $this->applyLabelForDrainedStream($streamIterator, $target); + }, + ); + } + + /** + * What the shutdown function registered by deferLabel() actually calls. + * Split out so a test can invoke it directly against a manually-drained + * iterator - PHP only invokes shutdown functions at the end of the + * whole test process, not per test. + * + * @param array{table: string, uid: int, origin: string} $target + */ + private function applyLabelForDrainedStream(StreamChunkIterator $streamIterator, array $target): void + { + if ($streamIterator->getAccumulatedContent() === '') { + return; + } + $this->applyLabel($target); + } + + /** + * @param array{table: string, uid: int, origin: string} $target + */ + private function applyLabel(array $target): void + { + try { + if ($target['origin'] === 'modified') { + $this->aiLabelApi->aiModified($target['table'], $target['uid']); + } else { + $this->aiLabelApi->aiCreated($target['table'], $target['uid']); + } + } catch (\Throwable $e) { + $this->logger->warning('Failed to flag {table}:{uid} via AiLabelApi: {message}', [ + 'table' => $target['table'], + 'uid' => $target['uid'], + 'message' => $e->getMessage(), + ]); + } + } +} diff --git a/Configuration/Services.php b/Configuration/Services.php new file mode 100644 index 0000000..c57aa80 --- /dev/null +++ b/Configuration/Services.php @@ -0,0 +1,18 @@ +register(FlagAiContentMiddleware::class, FlagAiContentMiddleware::class) + ->setAutowired(true) + ->addTag(\B13\Aim\Attribute\AsAiMiddleware::TAG_NAME, ['priority' => -850]); +}; diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index f7d184f..7b5f6b9 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -9,6 +9,12 @@ services: exclude: - '../Classes/Domain/Model/*' - '../Classes/Domain/Enum/*' + # Implements B13\Aim\Middleware\AiMiddlewareInterface - excluded from + # the normal resource scan so Symfony's container compilation never + # reflects (and therefore never needs to autoload/resolve that + # interface for) this class when b13/aim isn't installed. Registered + # manually, conditionally, in Configuration/Services.php instead. + - '../Classes/Middleware/FlagAiContentMiddleware.php' B13\AiLabel\DataProcessing\AiLabelProcessor: tags: diff --git a/Tests/Functional/Middleware/FlagAiContentMiddlewareTest.php b/Tests/Functional/Middleware/FlagAiContentMiddlewareTest.php new file mode 100644 index 0000000..e5edef3 --- /dev/null +++ b/Tests/Functional/Middleware/FlagAiContentMiddlewareTest.php @@ -0,0 +1,257 @@ +importCSVDataSet(__DIR__ . '/../Service/Fixtures/be_users.csv'); + $this->importCSVDataSet(__DIR__ . '/../Service/Fixtures/pages.csv'); + $this->backendUser = $GLOBALS['BE_USER'] = $this->setUpBackendUser(1); + + $content = $this->getConnectionPool()->getConnectionForTable('tt_content'); + $content->insert('tt_content', ['pid' => 1, 'header' => 'A teaser', 'CType' => 'text']); + $this->contentUid = (int)$content->lastInsertId(); + } + + private function createConfig(): ProviderConfiguration + { + return new ProviderConfiguration([ + 'uid' => 1, + 'ai_provider' => 'openai', + 'title' => 'Test', + 'api_key' => 'sk-test', + 'model' => 'gpt-4o', + ]); + } + + /** @return array{table: string, uid: int, origin?: string} */ + private function aiLabelTarget(string $origin = 'created'): array + { + return ['table' => 'tt_content', 'uid' => $this->contentUid, 'origin' => $origin]; + } + + #[Test] + public function isRegisteredAsAPublicServiceOnceAimIsInstalled(): void + { + // The actual point of this whole test class: proves Configuration/ + // Services.php's class_exists() guard resolved true and registered + // the service, not just that the class itself is syntactically fine. + self::assertInstanceOf(FlagAiContentMiddleware::class, $this->get(FlagAiContentMiddleware::class)); + } + + #[Test] + public function flagsRecordAsAiCreatedForASuccessfulResponse(): void + { + $config = $this->createConfig(); + $request = new TextGenerationRequest(configuration: $config, prompt: 'Hi', metadata: [ + 'aiLabel' => $this->aiLabelTarget('created'), + ]); + $response = new TextResponse('hello'); + $next = new AiMiddlewareHandler(static fn () => $response); + + $this->get(FlagAiContentMiddleware::class)->process($request, self::createStub(AiProviderInterface::class), $config, $next); + + self::assertSame(['origin' => 1, 'reviewed_by' => 0, 'reviewed_timestamp' => 0], $this->fetchMetadata()); + } + + #[Test] + public function flagsRecordAsAiModifiedWhenOriginIsModified(): void + { + $config = $this->createConfig(); + $request = new TextGenerationRequest(configuration: $config, prompt: 'Hi', metadata: [ + 'aiLabel' => $this->aiLabelTarget('modified'), + ]); + $response = new TextResponse('hello'); + $next = new AiMiddlewareHandler(static fn () => $response); + + $this->get(FlagAiContentMiddleware::class)->process($request, self::createStub(AiProviderInterface::class), $config, $next); + + self::assertSame(['origin' => 2, 'reviewed_by' => 0, 'reviewed_timestamp' => 0], $this->fetchMetadata()); + } + + #[Test] + public function doesNotCallAiLabelApiWhenResponseFailed(): void + { + $config = $this->createConfig(); + $request = new TextGenerationRequest(configuration: $config, prompt: 'Hi', metadata: [ + 'aiLabel' => $this->aiLabelTarget(), + ]); + $response = new TextResponse('', errors: ['boom']); + $next = new AiMiddlewareHandler(static fn () => $response); + + $result = $this->get(FlagAiContentMiddleware::class)->process($request, self::createStub(AiProviderInterface::class), $config, $next); + + self::assertSame($response, $result); + self::assertNull( + $this->getConnectionPool()->getConnectionForTable('tt_content') + ->select(['tx_ailabel_metadata'], 'tt_content', ['uid' => $this->contentUid])->fetchOne(), + ); + } + + #[Test] + public function doesNotCallAiLabelApiWhenNoMetadataIsSet(): void + { + $config = $this->createConfig(); + $request = new TextGenerationRequest(configuration: $config, prompt: 'Hi'); + $response = new TextResponse('hello'); + $next = new AiMiddlewareHandler(static fn () => $response); + + $result = $this->get(FlagAiContentMiddleware::class)->process($request, self::createStub(AiProviderInterface::class), $config, $next); + + self::assertSame($response, $result); + } + + #[Test] + public function doesNotCallAiLabelApiSynchronouslyForAnUndrainedStreamingResponse(): void + { + $config = $this->createConfig(); + $request = new ConversationRequest( + configuration: $config, + messages: [new UserMessage('Hi')], + stream: true, + metadata: ['aiLabel' => $this->aiLabelTarget()], + ); + $streamIterator = new StreamChunkIterator((function (): \Generator { + yield 'chunk'; + })(), $config); + $response = new ConversationResponse('', streamIterator: $streamIterator); + $next = new AiMiddlewareHandler(static fn () => $response); + + $this->get(FlagAiContentMiddleware::class)->process($request, self::createStub(AiProviderInterface::class), $config, $next); + + self::assertNull( + $this->getConnectionPool()->getConnectionForTable('tt_content') + ->select(['tx_ailabel_metadata'], 'tt_content', ['uid' => $this->contentUid])->fetchOne(), + ); + } + + #[Test] + public function appliesLabelOffTheDrainedIteratorOnceStreamIsConsumed(): void + { + // applyLabelForDrainedStream() is what the shutdown function + // registered by deferLabel() actually calls - tested directly here + // since PHP only invokes shutdown functions at the end of the whole + // test process. + $config = $this->createConfig(); + $streamIterator = new StreamChunkIterator((function (): \Generator { + yield 'the accumulated content'; + })(), $config); + iterator_to_array($streamIterator, false); + + $middleware = $this->get(FlagAiContentMiddleware::class); + (new \ReflectionMethod($middleware, 'applyLabelForDrainedStream'))->invoke($middleware, $streamIterator, $this->aiLabelTarget()); + + self::assertSame(['origin' => 1, 'reviewed_by' => 0, 'reviewed_timestamp' => 0], $this->fetchMetadata()); + } + + #[Test] + public function doesNotApplyLabelWhenDrainedIteratorHasNoAccumulatedContent(): void + { + $config = $this->createConfig(); + $streamIterator = new StreamChunkIterator((function (): \Generator { + if (false) { + yield ''; + } + })(), $config); + iterator_to_array($streamIterator, false); + + $middleware = $this->get(FlagAiContentMiddleware::class); + (new \ReflectionMethod($middleware, 'applyLabelForDrainedStream'))->invoke($middleware, $streamIterator, $this->aiLabelTarget()); + + self::assertNull( + $this->getConnectionPool()->getConnectionForTable('tt_content') + ->select(['tx_ailabel_metadata'], 'tt_content', ['uid' => $this->contentUid])->fetchOne(), + ); + } + + #[Test] + public function aFailedAiLabelApiCallDoesNotBreakTheResponse(): void + { + // No backend user at all in this specific case (unlike every other + // test here) - AiLabelApi::aiCreated() throws internally + // (resolveUser()) - the middleware must swallow that, not let it + // bubble up as an aim error. + unset($GLOBALS['BE_USER']); + + $config = $this->createConfig(); + $request = new TextGenerationRequest(configuration: $config, prompt: 'Hi', metadata: [ + 'aiLabel' => $this->aiLabelTarget(), + ]); + $response = new TextResponse('hello'); + $next = new AiMiddlewareHandler(static fn () => $response); + + $result = $this->get(FlagAiContentMiddleware::class)->process($request, self::createStub(AiProviderInterface::class), $config, $next); + + self::assertSame($response, $result); + } + + /** + * Decoded rather than compared as a raw string: MySQL's native JSON + * column type normalizes the stored value with spaces after colons/ + * commas on read-back, while sqlite returns it exactly as written + * (compact, no spaces) - comparing the decoded structure is the only + * assertion that holds across both. + * + * @return array|null + */ + private function fetchMetadata(): ?array + { + $raw = $this->getConnectionPool()->getConnectionForTable('tt_content') + ->select(['tx_ailabel_metadata'], 'tt_content', ['uid' => $this->contentUid])->fetchOne(); + return is_string($raw) ? json_decode($raw, true) : null; + } +} diff --git a/composer.json b/composer.json index be2ef4d..f1a2124 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,8 @@ }, "suggest": { "typo3/cms-fluid-styled-content": "Automatically renders the AI label on every content element via a DropIn/After/All.html override", - "typo3/cms-filelist": "Shows AI-label badges for flagged files in the File > Filelist backend module" + "typo3/cms-filelist": "Shows AI-label badges for flagged files in the File > Filelist backend module", + "b13/aim": "Enables FlagAiContentMiddleware, which automatically flags a record as AI-created/AI-modified once an aim request opts in via metadata" }, "minimum-stability": "dev", "prefer-stable": true, @@ -41,7 +42,8 @@ "typo3/coding-standards": "^0.5.5", "typo3/cms-workspaces": "^13.4 || ^14.3", "typo3/cms-filelist": "^13.4 || ^14.3", - "typo3/cms-fluid-styled-content": "^13.4 || ^14.3" + "typo3/cms-fluid-styled-content": "^13.4 || ^14.3", + "b13/aim": "*" }, "config": { "vendor-dir": ".Build/vendor",