New_Micro_Learning/backend/app/Modules/AI/Http/AiStudioController.php

284 خطوط
22 KiB
PHP

<?php
namespace App\Modules\AI\Http;
use App\Http\Controllers\Controller;
use App\Modules\AI\Application\AiProviderResolver;
use App\Modules\AI\Application\ProcessAiJob;
use App\Modules\Assets\Domain\Asset;
use App\Modules\Courses\Domain\Block;
use App\Modules\Courses\Domain\Course;
use App\Modules\Courses\Domain\CourseModule;
use App\Modules\Courses\Domain\CourseVersion;
use App\Modules\Courses\Domain\Lesson;
use App\Modules\Identity\Application\RolePermissions;
use App\Modules\Identity\Domain\Enums\Permission;
use App\Modules\Subscriptions\Domain\Subscription;
use App\Modules\System\Domain\DeploymentCapabilities;
use App\Modules\System\Domain\DeploymentMode;
use App\Modules\Taxonomy\Domain\ContentTaxonomyMapping;
use App\Modules\Taxonomy\Domain\Enums\MappableType;
use App\Modules\Taxonomy\Domain\Enums\MappingConfirmationStatus;
use App\Modules\Taxonomy\Domain\Enums\MappingSource;
use App\Modules\Taxonomy\Domain\Enums\MappingType;
use App\Modules\Tenancy\Application\TenantContext;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
final class AiStudioController extends Controller
{
public function __construct(private readonly TenantContext $tenant, private readonly RolePermissions $permissions, private readonly AiProviderResolver $providers, private readonly DeploymentCapabilities $deployment) {}
public function index(Request $request): JsonResponse
{
$this->author($request);
$items = DB::table('ai_jobs')->where('organization_id', $this->tenant->id())->where('requested_by', $request->user()->getKey())->latest()->limit(50)->get()->map(fn ($row) => $this->payload($row));
$subscription = $this->subscription();
$provider = $this->providers->forOrganization($this->tenant->id());
return response()->json(['data' => ['provider' => [...$this->providers->status($this->tenant->id()), 'enabled' => ! ($provider->external() && $this->deployment->mode() === DeploymentMode::OnPremise)], 'quota' => ['limit' => $subscription?->ai_credit_quota, 'used' => $subscription?->ai_credits_used ?? 0], 'items' => $items]]);
}
public function settings(Request $request): JsonResponse
{
$this->author($request);
$row = DB::table('organization_profiles')->where('organization_id', $this->tenant->id())->first();
$stored = json_decode($row?->settings ?: '{}', true);
return response()->json(['data' => ['settings' => array_replace_recursive($this->aiDefaults(), $stored['ai'] ?? []), 'providerStatus' => $this->providers->status($this->tenant->id())]]);
}
public function updateSettings(Request $request): JsonResponse
{
$this->author($request);
$data = $request->validate([
'settings' => ['required', 'array'],
'settings.provider' => ['required', 'string', 'max:100'],
'settings.courseModel' => ['required', 'string', 'max:255'],
'settings.assistModel' => ['required', 'string', 'max:255'],
'settings.fallbackProvider' => ['required', Rule::in(['local', 'none'])],
'settings.fallbackConnectionId' => ['nullable', 'string', 'max:26'],
'settings.capabilities' => ['required', 'array'], 'settings.capabilities.*' => ['boolean'],
'settings.quality' => ['required', 'array'], 'settings.quality.*' => [],
'settings.privacy' => ['required', 'array'], 'settings.privacy.*' => [],
'settings.limits' => ['required', 'array'], 'settings.limits.*' => [],
'settings.reliability' => ['required', 'array'], 'settings.reliability.*' => [],
]);
if (filled($data['settings']['fallbackConnectionId'] ?? null)) {
abort_unless(DB::table('ai_provider_connections')->where('organization_id', $this->tenant->id())->where('id', $data['settings']['fallbackConnectionId'])->exists(), 422, 'اتصال جایگزین معتبر نیست.');
}
$row = DB::table('organization_profiles')->where('organization_id', $this->tenant->id())->first();
$stored = json_decode($row?->settings ?: '{}', true);
$stored['ai'] = array_replace_recursive($this->aiDefaults(), $data['settings']);
DB::table('organization_profiles')->updateOrInsert(['organization_id' => $this->tenant->id()], ['id' => $row?->id ?? (string) str()->ulid(), 'settings' => json_encode($stored), 'created_at' => $row?->created_at ?? now(), 'updated_at' => now()]);
return $this->settings($request);
}
public function health(Request $request): JsonResponse
{
$this->author($request);
return response()->json(['data' => $this->providers->status($this->tenant->id())]);
}
public function store(Request $request): JsonResponse
{
$this->author($request);
$this->assertAiAvailable();
$data = $request->validate(['source' => ['nullable', 'file', 'max:204800', 'mimes:pdf,docx,pptx,zip'], 'sourceMode' => ['nullable', Rule::in(['topic', 'document', 'existing'])], 'topic' => ['nullable', 'string', 'max:240'], 'objective' => ['nullable', 'string', 'max:2000'], 'audience' => ['nullable', 'string', 'max:500'], 'duration' => ['nullable', 'integer', 'between:1,600'], 'difficulty' => ['nullable', Rule::in(['beginner', 'intermediate', 'advanced'])], 'tone' => ['nullable', Rule::in(['professional', 'friendly', 'formal'])], 'lessonCount' => ['nullable', 'integer', 'between:1,12'], 'assessmentLevel' => ['nullable', Rule::in(['none', 'knowledge', 'application', 'scenario'])], 'interactionDensity' => ['nullable', Rule::in(['low', 'medium', 'high'])], 'instructionalPattern' => ['nullable', Rule::in(['micro', 'scenario', 'story', 'practice'])], 'presentationMode' => ['nullable', Rule::in(['flow', 'slides'])], 'detail' => ['nullable', Rule::in(['concise', 'balanced', 'detailed'])], 'taxonomyNodeIds' => ['nullable', 'array', 'max:20'], 'taxonomyNodeIds.*' => ['string', 'distinct'], 'masteryLevel' => ['nullable', Rule::in(['awareness', 'foundation', 'applied', 'advanced', 'expert'])], 'language' => ['nullable', Rule::in(['fa', 'en'])], 'idempotencyKey' => ['nullable', 'uuid']]);
$nodeIds = $data['taxonomyNodeIds'] ?? [];
abort_if(count($nodeIds) !== DB::table('taxonomy_nodes')->where('organization_id', $this->tenant->id())->where('status', 'active')->whereIn('id', $nodeIds)->count(), 422, 'یک یا چند مهارت انتخاب‌شده معتبر نیست.');
$aiSettings = $this->currentAiSettings();
abort_unless((bool) ($aiSettings['capabilities']['courseGeneration'] ?? true), 403, 'ساخت دوره با هوش مصنوعی در تنظیمات غیرفعال است.');
$provider = $this->providers->forOrganization($this->tenant->id());
abort_if($request->hasFile('source') && $provider->external() && ! ($aiSettings['privacy']['allowExternalDocuments'] ?? false), 422, 'ارسال سند به ارائه‌دهنده خارجی در تنظیمات حریم خصوصی غیرفعال است.');
$activeJobs = DB::table('ai_jobs')->where('organization_id', $this->tenant->id())->whereIn('status', ['queued', 'processing'])->count();
abort_if($activeJobs >= (int) ($aiSettings['limits']['concurrentJobs'] ?? 2), 429, 'حداکثر پردازش هم‌زمان هوش مصنوعی در حال اجرا است.');
$monthlyJobs = DB::table('ai_jobs')->where('organization_id', $this->tenant->id())->where('requested_by', $request->user()->getKey())->where('created_at', '>=', now()->startOfMonth())->count();
abort_if($monthlyJobs >= (int) ($aiSettings['limits']['perUserMonthly'] ?? 50), 429, 'سقف ماهانه این کاربر برای هوش مصنوعی تکمیل شده است.');
$data = [...['language' => $aiSettings['quality']['language'] ?? 'fa', 'tone' => $aiSettings['quality']['tone'] ?? 'professional', 'lessonCount' => $aiSettings['quality']['lessonCount'] ?? 5, 'modelPolicy' => $aiSettings['courseModel'] ?? 'balanced'], ...$data];
abort_if(! $request->hasFile('source') && empty($data['topic']), 422, 'A source file or topic is required.');
$key = $data['idempotencyKey'] ?? (string) Str::uuid();
if ($existing = DB::table('ai_jobs')->where('organization_id', $this->tenant->id())->where('idempotency_key', $key)->first()) {
return response()->json(['data' => $this->payload($existing)]);
}
$id = (string) str()->ulid();
DB::table('ai_jobs')->insert(['id' => $id, 'organization_id' => $this->tenant->id(), 'requested_by' => $request->user()->getKey(), 'provider' => $provider->id(), 'operation' => 'course_draft', 'status' => 'queued', 'idempotency_key' => $key, 'input' => json_encode(collect($data)->except(['source'])->all()), 'progress' => 0, 'created_at' => now(), 'updated_at' => now()]);
if ($file = $request->file('source')) {
$subscription = $this->subscription();
$stored = (int) DB::table('assets')->where('organization_id', $this->tenant->id())->sum('size') + (int) DB::table('ai_source_documents')->where('organization_id', $this->tenant->id())->sum('size');
abort_if($subscription?->storage_quota_bytes !== null && $stored + $file->getSize() > $subscription->storage_quota_bytes, 422, 'Organization storage quota is exceeded.');
$extension = strtolower($file->getClientOriginalExtension());
$path = $file->storeAs('ai-sources/'.$this->tenant->id().'/'.$id, Str::uuid().'.'.$extension, 'local');
DB::table('ai_source_documents')->insert(['id' => (string) str()->ulid(), 'organization_id' => $this->tenant->id(), 'ai_job_id' => $id, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType() ?: 'application/octet-stream', 'size' => $file->getSize(), 'sha256' => hash_file('sha256', $file->getRealPath()), 'disk' => 'local', 'path' => $path, 'kind' => $extension === 'zip' ? 'scorm' : $extension, 'metadata' => json_encode(['schemaVersion' => 1]), 'created_at' => now(), 'updated_at' => now()]);
if ($extension === 'zip') {
$asset = new Asset(['organization_id' => $this->tenant->id(), 'uploaded_by' => $request->user()->getKey(), 'kind' => 'document', 'original_name' => $file->getClientOriginalName(), 'disk' => 'local', 'mime_type' => $file->getMimeType() ?: 'application/zip', 'size' => $file->getSize(), 'sha256' => hash_file('sha256', $file->getRealPath()), 'metadata' => ['contentType' => 'scorm', 'editable' => false, 'launchMode' => 'external_package']]);
$asset->id = (string) Str::ulid();
$asset->path = $file->storeAs('assets/'.$this->tenant->id().'/'.$asset->id, Str::uuid().'.zip', 'local');
$asset->save();
}
}
ProcessAiJob::dispatch($id);
return response()->json(['data' => $this->payload(DB::table('ai_jobs')->find($id))], 202);
}
public function show(Request $request, string $job): JsonResponse
{
$row = $this->job($request, $job);
$suggestion = DB::table('ai_suggestions')->where('ai_job_id', $row->id)->latest()->first();
$sources = DB::table('ai_source_documents')->where('ai_job_id', $row->id)->get()->map(fn ($document) => ['id' => $document->id, 'name' => $document->original_name, 'kind' => $document->kind, 'size' => $document->size, 'fragments' => DB::table('ai_source_fragments')->where('source_document_id', $document->id)->orderBy('position')->get(['id', 'locator', 'heading'])->map(fn ($fragment) => (array) $fragment)]);
return response()->json(['data' => [...$this->payload($row), 'sources' => $sources, 'suggestion' => $suggestion ? ['id' => $suggestion->id, 'status' => $suggestion->status, 'payload' => json_decode($suggestion->payload, true), 'confidence' => $suggestion->confidence, 'rationale' => $suggestion->rationale] : null]]);
}
public function cancel(Request $request, string $job): JsonResponse
{
$row = $this->job($request, $job);
abort_unless(in_array($row->status, ['queued', 'processing'], true), 409);
DB::table('ai_jobs')->where('id', $row->id)->update(['status' => 'cancelled', 'cancelled_at' => now(), 'updated_at' => now()]);
return response()->json(['data' => ['cancelled' => true]]);
}
public function retry(Request $request, string $job): JsonResponse
{
$row = $this->job($request, $job);
abort_unless($row->status === 'failed', 409);
DB::table('ai_jobs')->where('id', $row->id)->update(['status' => 'queued', 'progress' => 0, 'error' => null, 'started_at' => null, 'completed_at' => null, 'updated_at' => now()]);
ProcessAiJob::dispatch($row->id);
return response()->json(['data' => ['queued' => true]]);
}
public function assist(Request $request): JsonResponse
{
$this->author($request);
$data = $request->validate(['operation' => ['required', Rule::in(['generate_lesson', 'generate_quiz', 'rewrite', 'shorten', 'simplify', 'generate_examples', 'add_interaction', 'split_lesson', 'audit_course', 'check_objectives', 'check_assessment_alignment'])], 'content' => ['required', 'string', 'max:100000'], 'context' => ['nullable', 'array']]);
$settings = $this->currentAiSettings();
$context = [...($data['context'] ?? []), 'modelPolicy' => $settings['assistModel'] ?? 'fast'];
return response()->json(['data' => $this->providers->forOrganization($this->tenant->id())->assist($data['operation'], $data['content'], $context)]);
}
public function taxonomySuggestions(Request $request): JsonResponse
{
$this->author($request);
$data = $request->validate(['content' => ['required', 'string', 'max:50000']]);
$words = collect(preg_split('/\s+/u', mb_strtolower(strip_tags($data['content']))) ?: [])->filter(fn ($word) => mb_strlen($word) >= 3)->unique()->take(20);
$nodes = DB::table('taxonomy_nodes as n')->join('taxonomy_types as t', 't.id', '=', 'n.taxonomy_type_id')->where('n.organization_id', $this->tenant->id())->where('n.status', 'active')->whereIn('t.key', ['skill', 'skills', 'competency', 'competencies'])->get(['n.id', 'n.name', 'n.description', 't.key as kind']);
$suggestions = $nodes->map(function ($node) use ($words) {
$haystack = mb_strtolower($node->name.' '.($node->description ?? ''));
$matches = $words->filter(fn ($word) => str_contains($haystack, $word))->values();
return ['nodeId' => $node->id, 'name' => $node->name, 'kind' => $node->kind, 'confidence' => min(95, 45 + $matches->count() * 15), 'rationale' => $matches->isEmpty() ? 'Same-tenant taxonomy candidate; manual review required.' : 'Matched terms: '.$matches->implode(', '), 'status' => 'draft'];
})->sortByDesc('confidence')->take(8)->values();
return response()->json(['data' => $suggestions]);
}
public function accept(Request $request, string $suggestion): JsonResponse
{
$this->author($request);
$row = DB::table('ai_suggestions')->where('organization_id', $this->tenant->id())->where('id', $suggestion)->where('status', 'draft')->first();
abort_unless($row, 404);
$proposal = json_decode($row->payload, true);
$course = DB::transaction(function () use ($request, $row, $proposal): Course {
$course = Course::create(['organization_id' => $this->tenant->id(), 'title' => $proposal['title'], 'slug' => (Str::slug($proposal['title']) ?: 'ai-draft').'-'.Str::lower(Str::random(6)), 'status' => 'draft', 'created_by' => $request->user()->getKey()]);
$version = CourseVersion::create(['organization_id' => $this->tenant->id(), 'course_id' => $course->id, 'version_number' => 1, 'status' => 'draft', 'title' => $proposal['title'], 'description' => $proposal['description'] ?? null, 'settings' => [...($proposal['settings'] ?? []), 'aiProvenance' => ['jobId' => $row->ai_job_id, 'suggestionId' => $row->id, 'provider' => $proposal['providerDisclosure'] ?? null]]]);
foreach ($proposal['modules'] ?? [] as $mi => $moduleData) {
$module = CourseModule::create(['organization_id' => $this->tenant->id(), 'course_version_id' => $version->id, 'title' => $moduleData['title'], 'position' => $mi + 1]);
foreach ($moduleData['lessons'] ?? [] as $li => $lessonData) {
$lesson = Lesson::create(['organization_id' => $this->tenant->id(), 'course_version_id' => $version->id, 'course_module_id' => $module->id, 'title' => $lessonData['title'], 'position' => $li + 1, 'settings' => ['summary' => $lessonData['summary'] ?? null, 'sourceFragmentIds' => $lessonData['sourceFragmentIds'] ?? []]]);
foreach ($lessonData['blocks'] ?? [] as $bi => $blockData) {
Block::create(['organization_id' => $this->tenant->id(), 'course_version_id' => $version->id, 'lesson_id' => $lesson->id, 'type' => $blockData['type'], 'schema_version' => $blockData['schemaVersion'] ?? 1, 'data' => $blockData['data'], 'accessibility' => ['aiGenerated' => true, 'requiresHumanReview' => true], 'position' => $bi + 1]);
}
}
}
$jobInput = json_decode(DB::table('ai_jobs')->where('id', $row->ai_job_id)->value('input') ?: '{}', true);
foreach ($jobInput['taxonomyNodeIds'] ?? [] as $taxonomyNodeId) {
ContentTaxonomyMapping::query()->firstOrCreate([
'course_version_id' => $version->getKey(), 'mappable_type' => MappableType::Course, 'mappable_id' => $version->getKey(), 'taxonomy_node_id' => $taxonomyNodeId, 'mapping_type' => MappingType::Develops,
], [
'organization_id' => $this->tenant->id(), 'mastery_level' => $jobInput['masteryLevel'] ?? 'applied', 'weight' => 1, 'source' => MappingSource::Manual, 'confirmation_status' => MappingConfirmationStatus::Confirmed, 'confirmed_by' => $request->user()->getKey(), 'confirmed_at' => now(),
]);
}
DB::table('ai_suggestions')->where('id', $row->id)->update(['status' => 'accepted', 'entity_type' => 'course', 'entity_id' => $course->id, 'reviewed_by' => $request->user()->getKey(), 'reviewed_at' => now(), 'updated_at' => now()]);
return $course;
});
return response()->json(['data' => ['courseId' => $course->id, 'status' => 'draft']], 201);
}
public function reject(Request $request, string $suggestion): JsonResponse
{
$this->author($request);
$updated = DB::table('ai_suggestions')->where('organization_id', $this->tenant->id())->where('id', $suggestion)->where('status', 'draft')->update(['status' => 'rejected', 'reviewed_by' => $request->user()->getKey(), 'reviewed_at' => now(), 'updated_at' => now()]);
abort_unless($updated === 1, 404);
return response()->json(['data' => ['rejected' => true]]);
}
private function job(Request $request, string $id): object
{
$this->author($request);
$row = DB::table('ai_jobs')->where('organization_id', $this->tenant->id())->where('requested_by', $request->user()->getKey())->find($id);
abort_unless($row, 404);
return $row;
}
private function payload(object $row): array
{
return ['id' => $row->id, 'operation' => $row->operation, 'status' => $row->status, 'progress' => $row->progress, 'error' => $row->error, 'input' => json_decode($row->input ?: '{}', true), 'createdAt' => $row->created_at, 'completedAt' => $row->completed_at];
}
private function author(Request $request): void
{
abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403);
}
private function subscription(): ?Subscription
{
return Subscription::query()->where('organization_id', $this->tenant->id())->where('status', 'active')->where('starts_at', '<=', now())->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now()))->latest('starts_at')->first();
}
private function assertAiAvailable(): void
{
$provider = $this->providers->forOrganization($this->tenant->id());
abort_if($provider->external() && $this->deployment->mode() === DeploymentMode::OnPremise, 403, 'External AI is disabled for On-Premise deployments.');
$subscription = $this->subscription();
abort_if($subscription?->ai_credit_quota !== null && $subscription->ai_credits_used >= $subscription->ai_credit_quota, 422, 'Organization AI credit quota is exhausted.');
}
/** @return array<string, mixed> */
private function aiDefaults(): array
{
return [
'provider' => 'local', 'courseModel' => 'balanced', 'assistModel' => 'fast', 'fallbackProvider' => 'local', 'fallbackConnectionId' => null,
'capabilities' => ['courseGeneration' => true, 'lessonGeneration' => true, 'quizGeneration' => true, 'rewrite' => true, 'simplify' => true, 'examples' => true, 'interactions' => true, 'courseAudit' => true, 'skillMapping' => true, 'documentAnalysis' => true],
'quality' => ['sourceGrounding' => true, 'humanApproval' => true, 'autoPublish' => false, 'language' => 'fa', 'tone' => 'professional', 'detail' => 'balanced', 'lessonCount' => 5, 'contentSafety' => true],
'privacy' => ['allowExternalDocuments' => false, 'redactPersonalData' => true, 'retentionDays' => 30, 'logPrompts' => false, 'confirmExternal' => true],
'limits' => ['perUserMonthly' => 50, 'concurrentJobs' => 2, 'warningPercent' => 80, 'stopAtLimit' => true],
'reliability' => ['automaticRetry' => true, 'retryCount' => 2, 'fallbackEnabled' => true],
];
}
/** @return array<string, mixed> */
private function currentAiSettings(): array
{
$row = DB::table('organization_profiles')->where('organization_id', $this->tenant->id())->first();
$stored = json_decode($row?->settings ?: '{}', true);
return array_replace_recursive($this->aiDefaults(), $stored['ai'] ?? []);
}
}