Skip to content

Commit 796aebe

Browse files
authored
Merge pull request #23 from b13/connect-to-aim
[FEATURE] Add optional FlagAiContentMiddleware integration with EXT:aim
2 parents 720e83e + 7afee35 commit 796aebe

5 files changed

Lines changed: 456 additions & 2 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace B13\AiLabel\Middleware;
6+
7+
/*
8+
* This file is part of TYPO3 CMS-based extension "ai_label" by b13.
9+
*
10+
* It is free software; you can redistribute it and/or modify it under
11+
* the terms of the GNU General Public License, either version 2
12+
* of the License, or any later version.
13+
*/
14+
15+
use B13\AiLabel\Service\AiLabelApi;
16+
use B13\Aim\Domain\Model\ProviderConfiguration;
17+
use B13\Aim\Middleware\AiMiddlewareHandler;
18+
use B13\Aim\Middleware\AiMiddlewareInterface;
19+
use B13\Aim\Provider\AiProviderInterface;
20+
use B13\Aim\Request\AiRequestInterface;
21+
use B13\Aim\Response\ConversationResponse;
22+
use B13\Aim\Response\StreamChunkIterator;
23+
use B13\Aim\Response\TextResponse;
24+
use B13\Aim\Response\ToolCallingResponse;
25+
use Psr\Log\LoggerInterface;
26+
27+
/**
28+
* Optional integration with EXT:aim (b13/aim): flags a record as
29+
* AI-created/AI-modified via AiLabelApi once a request through aim's
30+
* middleware pipeline succeeds, purely opt-in via the request's own
31+
* metadata. AiM has no way to know which record a response's content will
32+
* end up in on its own (that's decided by whatever the calling extension
33+
* does with $response->content afterwards), so this is opt-in per call:
34+
*
35+
* $ai->text()->prompt(...)->withMetadata([
36+
* 'aiLabel' => ['table' => 'sys_file_metadata', 'uid' => $uid, 'origin' => 'created'],
37+
* ])->send();
38+
*
39+
* 'origin' is 'created' (brand-new AI content, the default if omitted) or
40+
* 'modified' (existing human content AI-edited), mirrors AiOrigin. Writing
41+
* through AiLabelApi requires a backend user to attribute the change to
42+
* (its own resolveUser()); calls made outside a backend context (frontend,
43+
* CLI/scheduler) will fail that check, which this middleware treats as a
44+
* non-fatal, logged no-op rather than surfacing it as an aim error.
45+
*
46+
* Only ever instantiated when b13/aim is actually installed: this class is
47+
* excluded from Services.yaml's normal `resource: '../Classes/*'` scan, so
48+
* Symfony's container compilation never reflects (and therefore never needs
49+
* to resolve AiMiddlewareInterface for) it when aim isn't present.
50+
* Configuration/Services.php registers and tags it manually instead, gated
51+
* behind a runtime class_exists() check.
52+
*
53+
* Priority -850 there, chosen the same way as any other aim
54+
* middleware: after CostTrackingMiddleware (-800) so the request is fully
55+
* settled, before EventDispatchMiddleware (-900) / CoreDispatchMiddleware
56+
* (-1000) so this only ever runs for genuinely successful responses.
57+
*/
58+
final class FlagAiContentMiddleware implements AiMiddlewareInterface
59+
{
60+
public function __construct(
61+
private readonly AiLabelApi $aiLabelApi,
62+
private readonly LoggerInterface $logger,
63+
) {
64+
}
65+
66+
public function process(
67+
AiRequestInterface $request,
68+
AiProviderInterface $provider,
69+
ProviderConfiguration $configuration,
70+
AiMiddlewareHandler $next,
71+
): TextResponse {
72+
$target = $this->resolveTarget($request);
73+
if ($target === null) {
74+
return $next->handle($request, $provider, $configuration);
75+
}
76+
77+
$response = $next->handle($request, $provider, $configuration);
78+
79+
if (($response instanceof ConversationResponse || $response instanceof ToolCallingResponse) && $response->isStreaming()) {
80+
$this->deferLabel($response, $target);
81+
return $response;
82+
}
83+
84+
if ($response->isSuccessful()) {
85+
$this->applyLabel($target);
86+
}
87+
88+
return $response;
89+
}
90+
91+
/**
92+
* @return array{table: string, uid: int, origin: string}|null
93+
*/
94+
private function resolveTarget(AiRequestInterface $request): ?array
95+
{
96+
if (!property_exists($request, 'metadata') || !is_array($request->metadata)) {
97+
return null;
98+
}
99+
100+
$raw = $request->metadata['aiLabel'] ?? null;
101+
if (!is_array($raw) || !isset($raw['table'], $raw['uid'])) {
102+
return null;
103+
}
104+
105+
$table = (string)$raw['table'];
106+
$uid = (int)$raw['uid'];
107+
if ($table === '' || $uid <= 0) {
108+
return null;
109+
}
110+
111+
$origin = (string)($raw['origin'] ?? 'created');
112+
if (!in_array($origin, ['created', 'modified'], true)) {
113+
$origin = 'created';
114+
}
115+
116+
return ['table' => $table, 'uid' => $uid, 'origin' => $origin];
117+
}
118+
119+
/**
120+
* @param array{table: string, uid: int, origin: string} $target
121+
*/
122+
private function deferLabel(ConversationResponse|ToolCallingResponse $response, array $target): void
123+
{
124+
$streamIterator = $response->streamIterator;
125+
if (!$streamIterator instanceof StreamChunkIterator) {
126+
return;
127+
}
128+
129+
register_shutdown_function(
130+
function () use ($streamIterator, $target): void {
131+
$this->applyLabelForDrainedStream($streamIterator, $target);
132+
},
133+
);
134+
}
135+
136+
/**
137+
* What the shutdown function registered by deferLabel() actually calls.
138+
* Split out so a test can invoke it directly against a manually-drained
139+
* iterator - PHP only invokes shutdown functions at the end of the
140+
* whole test process, not per test.
141+
*
142+
* @param array{table: string, uid: int, origin: string} $target
143+
*/
144+
private function applyLabelForDrainedStream(StreamChunkIterator $streamIterator, array $target): void
145+
{
146+
if ($streamIterator->getAccumulatedContent() === '') {
147+
return;
148+
}
149+
$this->applyLabel($target);
150+
}
151+
152+
/**
153+
* @param array{table: string, uid: int, origin: string} $target
154+
*/
155+
private function applyLabel(array $target): void
156+
{
157+
try {
158+
if ($target['origin'] === 'modified') {
159+
$this->aiLabelApi->aiModified($target['table'], $target['uid']);
160+
} else {
161+
$this->aiLabelApi->aiCreated($target['table'], $target['uid']);
162+
}
163+
} catch (\Throwable $e) {
164+
$this->logger->warning('Failed to flag {table}:{uid} via AiLabelApi: {message}', [
165+
'table' => $target['table'],
166+
'uid' => $target['uid'],
167+
'message' => $e->getMessage(),
168+
]);
169+
}
170+
}
171+
}

Configuration/Services.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use B13\AiLabel\Middleware\FlagAiContentMiddleware;
6+
use Symfony\Component\DependencyInjection\ContainerBuilder;
7+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
8+
9+
return static function (ContainerConfigurator $container, ContainerBuilder $containerBuilder): void {
10+
// Optional integration with b13/aim: registered and tagged manually
11+
// (rather than via aim's own #[AsAiMiddleware] attribute directly on the class).
12+
if (!class_exists(\B13\Aim\Attribute\AsAiMiddleware::class)) {
13+
return;
14+
}
15+
$containerBuilder->register(FlagAiContentMiddleware::class, FlagAiContentMiddleware::class)
16+
->setAutowired(true)
17+
->addTag(\B13\Aim\Attribute\AsAiMiddleware::TAG_NAME, ['priority' => -850]);
18+
};

Configuration/Services.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ services:
99
exclude:
1010
- '../Classes/Domain/Model/*'
1111
- '../Classes/Domain/Enum/*'
12+
# Implements B13\Aim\Middleware\AiMiddlewareInterface - excluded from
13+
# the normal resource scan so Symfony's container compilation never
14+
# reflects (and therefore never needs to autoload/resolve that
15+
# interface for) this class when b13/aim isn't installed. Registered
16+
# manually, conditionally, in Configuration/Services.php instead.
17+
- '../Classes/Middleware/FlagAiContentMiddleware.php'
1218

1319
B13\AiLabel\DataProcessing\AiLabelProcessor:
1420
tags:

0 commit comments

Comments
 (0)