trypost/app/Ai/Tools/GenerateAudio.php
Paulo Castellano 5f7768ca3a fix: apply code review — enforce quota server-side, project conventions
Critical fixes:
- Quota enforcement regressed when the regex flow was replaced with
  tools. The LLM was trusted to respect [Session state] quota hints,
  which is vulnerable to prompt injection / hallucination. Each tool
  now checks Pennant feature limits at the top of handle() and returns
  a short quota-exhausted string instead of calling the provider.
  Audio shares the video quota (there is no AiAudioLimit by design).
- N+1 on aiMessages: combined the two separate ->get() scans that
  produced imagesInThread and videosInThread into a single query.
- postPlatforms lazy load: controller now loadMissing('postPlatforms')
  before constructing SocialMediaAssistant, which accesses the relation
  in activePlatformRules().

Project conventions:
- \RuntimeException and \Throwable are now imported at the top of the
  controller instead of inlined per CLAUDE.md.
- Tool handle() methods use data_get($request, 'prompt') instead of
  direct array access, matching the data_get convention for service
  classes.
- enrichContent() pluralizes attachment types via Str::plural so
  '2 images' reads naturally instead of '2 image'.

Tests:
- Added Storage::fake('public') in tool tests so runs don't pollute
  the local disk with fake-generated files.
- Restored quota enforcement coverage (deleted when the flow changed)
  as three new tests — one per tool — that fill the monthly bucket
  and assert the tool refuses and nothing is generated.
- Removed inline \App\ references in tests in favor of imports.
2026-04-16 10:22:55 -03:00

103 lines
3.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Ai\Tools;
use App\Enums\Ai\UsageType;
use App\Features\AiVideosLimit;
use App\Models\AiUsageLog;
use App\Models\Post;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Ai\Audio;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Laravel\Pennant\Feature;
use Stringable;
class GenerateAudio implements Tool
{
public function __construct(
public Workspace $workspace,
public ?Post $post = null,
public ?string $userId = null,
public ?AttachmentCollector $collector = null,
) {
$this->collector ??= app(AttachmentCollector::class);
}
public function description(): Stringable|string
{
return <<<'TXT'
Generate an AI voiceover or narration audio from text.
Call this when the user asks for a voiceover, narration, TTS, podcast clip,
or any voice content. The output language will match the language of the
input text — so pass the text in the target language.
Audio is generated via ElevenLabs with the configured default voice.
TXT;
}
public function handle(Request $request): Stringable|string
{
// Audio generation counts against the monthly video quota by design.
$limit = (int) Feature::for($this->workspace->account)->value(AiVideosLimit::class);
$used = AiUsageLog::monthlyCount($this->workspace->account_id, UsageType::Video)
+ AiUsageLog::monthlyCount($this->workspace->account_id, UsageType::Audio);
if ($used >= $limit) {
return "Audio and video share a monthly quota that is exhausted ({$used} of {$limit} used). Ask the user to upgrade their plan or wait until next month.";
}
$text = (string) data_get($request, 'text', '');
$voice = config('services.elevenlabs.default_voice', 'EXAVITQu4vr4xnSDxMaL');
$response = Audio::of($text)->voice($voice)->generate();
$storedPath = $response->store('medias', 'public');
$media = $this->workspace->media()->create([
'group_id' => Str::uuid()->toString(),
'collection' => 'assets',
'type' => 'video',
'path' => $storedPath,
'original_filename' => 'ai-generated.mp3',
'mime_type' => 'audio/mpeg',
'size' => Storage::disk('public')->size($storedPath),
'order' => 0,
'meta' => ['ai_generated' => true, 'text' => Str::limit($text, 200)],
]);
AiUsageLog::create([
'account_id' => $this->workspace->account_id,
'workspace_id' => $this->workspace->id,
'user_id' => $this->userId,
'post_id' => $this->post?->id,
'type' => UsageType::Audio,
'provider' => 'elevenlabs',
]);
$this->collector->push([
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'mime_type' => 'audio/mpeg',
'type' => 'audio',
]);
return "Generated audio (id: {$media->id}) and attached it to the post.";
}
public function schema(JsonSchema $schema): array
{
return [
'text' => $schema->string()
->description('The text to convert into speech. Write it in the target language — the output voice language will match.')
->required(),
];
}
}