trypost/app/Http/Controllers/App/PostAssistantController.php

174 lines
5.8 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Ai\Agents\SocialMediaAssistant;
use App\Ai\Tools\AttachmentCollector;
use App\Enums\Ai\Intent;
use App\Enums\Ai\UsageType;
use App\Features\AiImagesLimit;
use App\Features\AiVideosLimit;
use App\Http\Requests\App\Assistant\StoreAssistantMessageRequest;
use App\Models\AiUsageLog;
use App\Models\Post;
use App\Services\Ai\IntentDetector;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Pennant\Feature;
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 13:22:55 +00:00
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
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 13:22:55 +00:00
use Throwable;
class PostAssistantController extends Controller
{
public function index(Request $request, Post $post): JsonResponse
{
$workspace = $request->user()->currentWorkspace;
if ($post->workspace_id !== $workspace->id) {
abort(Response::HTTP_FORBIDDEN);
}
$messages = $post->aiMessages()
->with('user')
->oldest()
->get();
return response()->json(['messages' => $messages]);
}
public function store(
StoreAssistantMessageRequest $request,
Post $post,
IntentDetector $intentDetector,
AttachmentCollector $collector,
): JsonResponse {
$workspace = $request->user()->currentWorkspace;
if ($post->workspace_id !== $workspace->id) {
abort(Response::HTTP_FORBIDDEN);
}
$validated = $request->validated();
$prompt = data_get($validated, 'body');
$userMessage = $post->aiMessages()->create([
'user_id' => $request->user()->id,
'role' => 'user',
'content' => $prompt,
]);
if ($request->hasFile('image')) {
$media = $workspace->addMedia($request->file('image'), 'assets');
$userMessage->update([
'attachments' => [['id' => $media->id, 'path' => $media->path, 'url' => $media->url, 'type' => 'image', 'mime_type' => $media->mime_type]],
]);
}
$userMessage->load('user');
$intent = $intentDetector->detect($prompt);
if ($intent === Intent::Blocked) {
$assistantMessage = $post->aiMessages()->create([
'role' => 'assistant',
'content' => __('assistant.content_blocked'),
'metadata' => ['intent' => $intent->value, 'error' => true],
]);
return response()->json([
'user_message' => $userMessage,
'assistant_message' => $assistantMessage,
], Response::HTTP_CREATED);
}
try {
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 13:22:55 +00:00
$post->loadMissing('postPlatforms');
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 13:22:55 +00:00
$assistantMessages = $post->aiMessages()
->where('role', 'assistant')
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 13:22:55 +00:00
->get();
$imagesInThread = $assistantMessages->sum(
fn ($m) => collect($m->attachments ?? [])->where('type', 'image')->count()
);
$videosInThread = $assistantMessages->sum(
fn ($m) => collect($m->attachments ?? [])->where('type', 'video')->count()
);
$imageLimit = (int) Feature::for($workspace->account)->value(AiImagesLimit::class);
$imageUsed = AiUsageLog::monthlyCount($workspace->account_id, UsageType::Image);
$imageRemaining = max(0, $imageLimit - $imageUsed);
$videoLimit = (int) Feature::for($workspace->account)->value(AiVideosLimit::class);
$videoUsed = AiUsageLog::monthlyCount($workspace->account_id, UsageType::Video);
$videoRemaining = max(0, $videoLimit - $videoUsed);
$stateContext = sprintf(
"[Session state — use this to track progress and respect quotas]\n".
"- Images already generated in this conversation: %d\n".
"- Videos already generated in this conversation: %d\n".
"- Monthly quota remaining: %d images, %d videos\n",
$imagesInThread,
$videosInThread,
$imageRemaining,
$videoRemaining,
);
$promptWithState = "{$stateContext}\n{$prompt}";
$collector->clear();
$response = (new SocialMediaAssistant(
workspace: $workspace,
post: $post,
userId: $request->user()->id,
))->prompt($promptWithState);
$responseContent = $response->text;
$attachments = $collector->all();
$generatedIntent = $intent->value;
foreach ($attachments as $attachment) {
if (isset($attachment['type'])) {
$generatedIntent = $attachment['type'];
break;
}
}
$assistantMessage = $post->aiMessages()->create([
'role' => 'assistant',
'content' => $responseContent,
'attachments' => $attachments,
'metadata' => ['intent' => $generatedIntent],
]);
return response()->json([
'user_message' => $userMessage,
'assistant_message' => $assistantMessage,
], Response::HTTP_CREATED);
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 13:22:55 +00:00
} catch (Throwable $e) {
Log::error('PostAssistantController error', ['error' => $e->getMessage()]);
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 13:22:55 +00:00
$errorMessage = $e instanceof RuntimeException ? $e->getMessage() : __('assistant.error');
$assistantMessage = $post->aiMessages()->create([
'role' => 'assistant',
'content' => $errorMessage,
'metadata' => ['intent' => $intent->value, 'error' => true],
]);
return response()->json([
'user_message' => $userMessage,
'assistant_message' => $assistantMessage,
], Response::HTTP_CREATED);
}
}
}