find($this->jobId); if (! $job || $job->cancelled_at) { return; } DB::table('ai_jobs')->where('id', $this->jobId)->update(['status' => 'processing', 'progress' => 10, 'started_at' => now(), 'updated_at' => now()]); try { $input = json_decode($job->input ?: '{}', true); $fragments = []; foreach (DB::table('ai_source_documents')->where('ai_job_id', $this->jobId)->get() as $document) { foreach ($extractor->extract(Storage::disk($document->disk)->path($document->path), $document->kind) as $position => $fragment) { $id = (string) str()->ulid(); DB::table('ai_source_fragments')->insert(['id' => $id, 'organization_id' => $job->organization_id, 'source_document_id' => $document->id, 'position' => $position + 1, 'locator' => $fragment['locator'], 'heading' => $fragment['heading'], 'content' => $fragment['content'], 'content_hash' => hash('sha256', $fragment['content']), 'metadata' => json_encode(['schemaVersion' => 1]), 'created_at' => now(), 'updated_at' => now()]); $fragments[] = ['id' => $id, ...$fragment]; } } if ($fragments === []) { $fragments[] = ['id' => 'prompt', 'locator' => null, 'heading' => $input['topic'] ?? null, 'content' => $input['objective'] ?? $input['topic'] ?? '']; } DB::table('ai_jobs')->where('id', $this->jobId)->update(['progress' => 65, 'updated_at' => now()]); try { $proposal = $providers->byId($job->provider, $job->organization_id)->structure($fragments, $input); } catch (Throwable $providerException) { $fallback = $providers->fallbackForOrganization($job->organization_id, $job->provider); if (! $fallback) { throw $providerException; } $proposal = $fallback->structure($fragments, $input); $proposal['fallbackDisclosure'] = 'The configured external provider failed; the organization fallback provider generated this draft.'; } $suggestionId = (string) str()->ulid(); DB::table('ai_suggestions')->insert(['id' => $suggestionId, 'organization_id' => $job->organization_id, 'ai_job_id' => $this->jobId, 'suggestion_type' => 'course_draft', 'payload' => json_encode($proposal), 'confidence' => 70, 'rationale' => 'Source-grounded structure requires Designer review before acceptance.', 'status' => 'draft', 'created_at' => now(), 'updated_at' => now()]); DB::table('ai_jobs')->where('id', $this->jobId)->update(['status' => 'completed', 'progress' => 100, 'output' => json_encode(['suggestionId' => $suggestionId]), 'input_units' => array_sum(array_map(fn ($item) => mb_strlen($item['content']), $fragments)), 'output_units' => mb_strlen(json_encode($proposal)), 'completed_at' => now(), 'updated_at' => now()]); DB::table('subscriptions')->where('organization_id', $job->organization_id)->where('status', 'active')->whereNotNull('ai_credit_quota')->increment('ai_credits_used'); } catch (Throwable $exception) { DB::table('ai_jobs')->where('id', $this->jobId)->update(['status' => 'failed', 'error' => mb_substr($exception->getMessage(), 0, 4000), 'updated_at' => now()]); throw $exception; } } }