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 */ 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 */ 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'] ?? []); } }