785 lines
36 KiB
PHP
785 lines
36 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Academy;
|
|
|
|
use App\Models\AcademyContentMetricDaily;
|
|
use App\Models\AcademySearchLog;
|
|
use App\Models\AcademyUserProgress;
|
|
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class AcademyContentIntelligenceService
|
|
{
|
|
public function __construct(private readonly AcademyAnalyticsContentResolver $resolver) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getContentOpportunities(array $filters = []): array
|
|
{
|
|
return $this->remember('content-opportunities', $filters, function (Carbon $from, Carbon $to, int $limit): array {
|
|
$searchGaps = $this->getSearchGaps(['from' => $from, 'to' => $to, 'limit' => $limit]);
|
|
$promptInsights = $this->getPromptInsights(['from' => $from, 'to' => $to, 'limit' => $limit]);
|
|
$lessonDropoffs = $this->getLessonDropoffs(['from' => $from, 'to' => $to, 'limit' => $limit]);
|
|
$courseHealth = $this->getCourseHealth(['from' => $from, 'to' => $to, 'limit' => $limit]);
|
|
$premiumInterest = $this->getPremiumInterest(['from' => $from, 'to' => $to, 'limit' => $limit]);
|
|
$recommendations = $this->getEditorialRecommendations(['from' => $from, 'to' => $to, 'limit' => $limit]);
|
|
|
|
$cards = [
|
|
[
|
|
'label' => 'Content opportunities',
|
|
'value' => count($recommendations['rows']),
|
|
'description' => 'Actionable content, conversion, and editorial recommendations generated from Academy analytics.',
|
|
],
|
|
[
|
|
'label' => 'Search gaps',
|
|
'value' => (int) $searchGaps['summary']['gap_count'],
|
|
'description' => 'Queries with zero results, weak CTR, or no result clicks.',
|
|
],
|
|
[
|
|
'label' => 'Prompt insights',
|
|
'value' => (int) $promptInsights['summary']['signal_count'],
|
|
'description' => 'Prompts that should be improved, promoted, or expanded.',
|
|
],
|
|
[
|
|
'label' => 'Lesson drop-offs',
|
|
'value' => (int) $lessonDropoffs['summary']['signal_count'],
|
|
'description' => 'Lessons losing users before they meaningfully start or finish.',
|
|
],
|
|
[
|
|
'label' => 'Course health',
|
|
'value' => (int) $courseHealth['summary']['signal_count'],
|
|
'description' => 'Courses that need restructuring or are ready for expansion.',
|
|
],
|
|
[
|
|
'label' => 'Premium interest',
|
|
'value' => (int) $premiumInterest['summary']['signal_count'],
|
|
'description' => 'Content that shows premium teaser strength or weakness.',
|
|
],
|
|
[
|
|
'label' => 'Editorial recommendations',
|
|
'value' => (int) $recommendations['summary']['total'],
|
|
'description' => 'Prioritized actions for what to create, improve, promote, or premiumize next.',
|
|
],
|
|
];
|
|
|
|
return [
|
|
'cards' => $cards,
|
|
'highlights' => collect($recommendations['rows'])
|
|
->take(6)
|
|
->map(fn (array $row): array => [
|
|
'title' => (string) $row['title'],
|
|
'priority' => (string) $row['priority'],
|
|
'reason' => (string) $row['reason'],
|
|
'suggested_action' => (string) $row['suggested_action'],
|
|
])
|
|
->values()
|
|
->all(),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getSearchGaps(array $filters = []): array
|
|
{
|
|
return $this->remember('search-gaps', $filters, function (Carbon $from, Carbon $to, int $limit): array {
|
|
$rows = $this->searchQuery($from, $to)->get()->map(function ($row): array {
|
|
$searches = max(0, (int) $row->searches);
|
|
$clicks = max(0, (int) ($row->clicks ?? 0));
|
|
$resultsCount = round((float) ($row->avg_results_count ?? 0), 1);
|
|
$ctr = $searches > 0 ? round(($clicks / $searches) * 100, 1) : 0.0;
|
|
$signal = $this->classifySearchGap(
|
|
searches: $searches,
|
|
resultsCount: $resultsCount,
|
|
clicks: $clicks,
|
|
ctr: $ctr,
|
|
loggedInSearches: max(0, (int) ($row->logged_in_searches ?? 0)),
|
|
subscriberSearches: max(0, (int) ($row->subscriber_searches ?? 0)),
|
|
);
|
|
|
|
return [
|
|
'query' => (string) ($row->query ?: $row->normalized_query),
|
|
'normalized_query' => (string) $row->normalized_query,
|
|
'searches' => $searches,
|
|
'results_count' => $resultsCount,
|
|
'clicks' => $clicks,
|
|
'ctr' => $ctr,
|
|
'last_searched_at' => $row->last_searched_at ? Carbon::parse((string) $row->last_searched_at)->toDateTimeString() : null,
|
|
'logged_in_searches' => max(0, (int) ($row->logged_in_searches ?? 0)),
|
|
'subscriber_searches' => max(0, (int) ($row->subscriber_searches ?? 0)),
|
|
'issue' => $signal['issue'],
|
|
'priority' => $signal['priority'],
|
|
'priority_score' => $signal['priority_score'],
|
|
'suggested_action' => $signal['suggested_action'],
|
|
];
|
|
})->filter(fn (array $row): bool => $row['issue'] !== null)->values();
|
|
|
|
$zeroResultSearches = $rows
|
|
->filter(fn (array $row): bool => $row['issue'] === 'Zero-result demand')
|
|
->sortByDesc('searches')
|
|
->take($limit)
|
|
->values();
|
|
$searchesWithResultsNoClicks = $rows
|
|
->filter(fn (array $row): bool => $row['issue'] === 'Results with no clicks')
|
|
->sortByDesc('searches')
|
|
->take($limit)
|
|
->values();
|
|
$lowCtrSearches = $rows
|
|
->filter(fn (array $row): bool => $row['issue'] === 'Low click-through rate')
|
|
->sortBy('ctr')
|
|
->take($limit)
|
|
->values();
|
|
$highCtrSearches = $rows
|
|
->filter(fn (array $row): bool => $row['issue'] === 'High click-through topic')
|
|
->sortByDesc('ctr')
|
|
->take($limit)
|
|
->values();
|
|
$repeatedQueries = $rows
|
|
->filter(fn (array $row): bool => $row['logged_in_searches'] >= 2 || $row['subscriber_searches'] >= 2)
|
|
->sortByDesc('searches')
|
|
->take($limit)
|
|
->values();
|
|
|
|
$dedupedRows = $this->dedupeByKey(
|
|
collect([$zeroResultSearches, $searchesWithResultsNoClicks, $lowCtrSearches, $highCtrSearches])
|
|
->flatten(1)
|
|
->sortByDesc('priority_score')
|
|
->sortByDesc('searches')
|
|
->values(),
|
|
'normalized_query',
|
|
)->take($limit)->values();
|
|
|
|
return [
|
|
'summary' => [
|
|
'gap_count' => $dedupedRows->count(),
|
|
'zero_result_count' => $zeroResultSearches->count(),
|
|
'no_click_count' => $searchesWithResultsNoClicks->count(),
|
|
'low_ctr_count' => $lowCtrSearches->count(),
|
|
'high_ctr_count' => $highCtrSearches->count(),
|
|
'repeated_member_count' => $repeatedQueries->count(),
|
|
],
|
|
'rows' => $dedupedRows->all(),
|
|
'zero_result_searches' => $zeroResultSearches->all(),
|
|
'searches_with_results_no_clicks' => $searchesWithResultsNoClicks->all(),
|
|
'low_ctr_searches' => $lowCtrSearches->all(),
|
|
'high_ctr_searches' => $highCtrSearches->all(),
|
|
'repeated_queries' => $repeatedQueries->all(),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getPromptInsights(array $filters = []): array
|
|
{
|
|
return $this->remember('prompt-insights', $filters, function (Carbon $from, Carbon $to, int $limit): array {
|
|
$rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT)->map(function (array $row): ?array {
|
|
$signal = $this->classifyPromptInsight($row);
|
|
|
|
if ($signal === null) {
|
|
return null;
|
|
}
|
|
|
|
return array_merge($row, $signal);
|
|
})->filter()->sortByDesc('priority_score')->take($limit)->values();
|
|
|
|
return [
|
|
'summary' => [
|
|
'signal_count' => $rows->count(),
|
|
'high_view_low_copy' => $rows->where('issue', 'High views, low copies')->count(),
|
|
'low_view_high_copy_rate' => $rows->where('issue', 'Low views, high copy rate')->count(),
|
|
'high_save_low_copy' => $rows->where('issue', 'High saves, low copies')->count(),
|
|
'high_copy_low_like' => $rows->where('issue', 'High copies, low likes')->count(),
|
|
'high_upgrade_interest' => $rows->where('issue', 'High upgrade interest')->count(),
|
|
],
|
|
'rows' => $rows->all(),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getLessonDropoffs(array $filters = []): array
|
|
{
|
|
return $this->remember('lesson-dropoffs', $filters, function (Carbon $from, Carbon $to, int $limit): array {
|
|
$rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::LESSON)->map(function (array $row): ?array {
|
|
$signal = $this->classifyLessonDropoff($row);
|
|
|
|
if ($signal === null) {
|
|
return null;
|
|
}
|
|
|
|
return array_merge($row, $signal);
|
|
})->filter()->sortByDesc('priority_score')->take($limit)->values();
|
|
|
|
return [
|
|
'summary' => [
|
|
'signal_count' => $rows->count(),
|
|
'low_start_rate' => $rows->where('issue', 'High views, low starts')->count(),
|
|
'low_completion_rate' => $rows->where('issue', 'High starts, low completions')->count(),
|
|
'underpromoted_winners' => $rows->where('issue', 'High completions, low views')->count(),
|
|
'upgrade_interest' => $rows->where('issue', 'Upgrade interest')->count(),
|
|
],
|
|
'rows' => $rows->all(),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getCourseHealth(array $filters = []): array
|
|
{
|
|
return $this->remember('course-health', $filters, function (Carbon $from, Carbon $to, int $limit): array {
|
|
$progress = AcademyUserProgress::query()
|
|
->selectRaw('course_id, avg(progress_percent) as avg_progress_percent, count(*) as learners')
|
|
->whereNotNull('course_id')
|
|
->whereNull('lesson_id')
|
|
->whereBetween('updated_at', [$from, $to])
|
|
->groupBy('course_id')
|
|
->get()
|
|
->keyBy(fn ($row): int => (int) $row->course_id);
|
|
|
|
$rows = $this->contentMetrics($from, $to, AcademyAnalyticsContentType::COURSE)->map(function (array $row) use ($progress): ?array {
|
|
$courseProgress = $progress->get((int) $row['content_id']);
|
|
$row['avg_progress'] = $courseProgress ? round((float) ($courseProgress->avg_progress_percent ?? 0), 1) : 0.0;
|
|
$row['learners'] = $courseProgress ? (int) ($courseProgress->learners ?? 0) : 0;
|
|
$signal = $this->classifyCourseHealth($row);
|
|
|
|
if ($signal === null) {
|
|
return null;
|
|
}
|
|
|
|
return array_merge($row, $signal);
|
|
})->filter()->sortByDesc('priority_score')->take($limit)->values();
|
|
|
|
return [
|
|
'summary' => [
|
|
'signal_count' => $rows->count(),
|
|
'low_start_rate' => $rows->where('issue', 'Low course start rate')->count(),
|
|
'low_completion_rate' => $rows->where('issue', 'Low course completion rate')->count(),
|
|
'expandable_courses' => $rows->where('issue', 'Expansion candidate')->count(),
|
|
'upgrade_interest' => $rows->where('issue', 'Premium follow-up opportunity')->count(),
|
|
],
|
|
'rows' => $rows->all(),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getPremiumInterest(array $filters = []): array
|
|
{
|
|
return $this->remember('premium-interest', $filters, function (Carbon $from, Carbon $to, int $limit): array {
|
|
$rows = collect([
|
|
...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT)->all(),
|
|
...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::LESSON)->all(),
|
|
...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::COURSE)->all(),
|
|
...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::PROMPT_PACK)->all(),
|
|
...$this->contentMetrics($from, $to, AcademyAnalyticsContentType::CHALLENGE)->all(),
|
|
])->map(function (array $row): ?array {
|
|
$signal = $this->classifyPremiumInterest($row);
|
|
|
|
if ($signal === null) {
|
|
return null;
|
|
}
|
|
|
|
return array_merge($row, $signal, [
|
|
'premium_interest_score' => round(((float) $row['premium_preview_views'] * 2) + ((float) $row['upgrade_clicks'] * 10), 1),
|
|
]);
|
|
})->filter()->sortByDesc('priority_score')->sortByDesc('premium_interest_score')->take($limit)->values();
|
|
|
|
return [
|
|
'summary' => [
|
|
'signal_count' => $rows->count(),
|
|
'strong_candidates' => $rows->where('issue', 'Strong premium candidate')->count(),
|
|
'weak_teasers' => $rows->where('issue', 'Weak premium teaser')->count(),
|
|
],
|
|
'rows' => $rows->all(),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getEditorialRecommendations(array $filters = []): array
|
|
{
|
|
return $this->remember('editorial-recommendations', $filters, function (Carbon $from, Carbon $to, int $limit): array {
|
|
$searchGaps = $this->getSearchGaps(['from' => $from, 'to' => $to, 'limit' => $limit]);
|
|
$promptInsights = $this->getPromptInsights(['from' => $from, 'to' => $to, 'limit' => $limit]);
|
|
$lessonDropoffs = $this->getLessonDropoffs(['from' => $from, 'to' => $to, 'limit' => $limit]);
|
|
$courseHealth = $this->getCourseHealth(['from' => $from, 'to' => $to, 'limit' => $limit]);
|
|
$premiumInterest = $this->getPremiumInterest(['from' => $from, 'to' => $to, 'limit' => $limit]);
|
|
|
|
$recommendations = collect();
|
|
|
|
foreach (array_slice($searchGaps['zero_result_searches'], 0, 5) as $row) {
|
|
$recommendations->push([
|
|
'title' => sprintf('Create content for "%s"', $row['query']),
|
|
'description' => sprintf('Users searched for "%s" %d times and saw %.1f results.', $row['query'], $row['searches'], $row['results_count']),
|
|
'reason' => 'Repeated zero-result searches indicate missing Academy content coverage.',
|
|
'priority' => $row['searches'] >= 3 ? 'high' : 'medium',
|
|
'priority_score' => $row['searches'] >= 3 ? 300 + $row['searches'] : 200 + $row['searches'],
|
|
'content_type' => null,
|
|
'content_id' => null,
|
|
'metric_snapshot' => [
|
|
'searches' => $row['searches'],
|
|
'results_count' => $row['results_count'],
|
|
'clicks' => $row['clicks'],
|
|
],
|
|
'suggested_action' => 'Create content for this topic',
|
|
]);
|
|
}
|
|
|
|
foreach (array_slice($promptInsights['rows'], 0, 4) as $row) {
|
|
$recommendations->push([
|
|
'title' => sprintf('Review prompt "%s"', $row['title']),
|
|
'description' => sprintf('%s with %d views, %d copies, and a %.1f%% copy rate.', $row['issue'], $row['views'], $row['prompt_copies'], $row['copy_rate']),
|
|
'reason' => 'Prompt performance suggests either discoverability or quality improvements are needed.',
|
|
'priority' => $row['priority'],
|
|
'priority_score' => 180 + (int) $row['priority_score'],
|
|
'content_type' => $row['content_type'],
|
|
'content_id' => $row['content_id'],
|
|
'metric_snapshot' => [
|
|
'views' => $row['views'],
|
|
'copies' => $row['prompt_copies'],
|
|
'copy_rate' => $row['copy_rate'],
|
|
'upgrade_clicks' => $row['upgrade_clicks'],
|
|
],
|
|
'suggested_action' => $row['suggested_action'],
|
|
]);
|
|
}
|
|
|
|
foreach (array_slice($lessonDropoffs['rows'], 0, 4) as $row) {
|
|
$recommendations->push([
|
|
'title' => sprintf('Improve lesson "%s"', $row['title']),
|
|
'description' => sprintf('%s with %d starts and a %.1f%% completion rate.', $row['issue'], $row['starts'], $row['completion_rate']),
|
|
'reason' => 'Lesson funnel data shows where learners hesitate or drop off.',
|
|
'priority' => $row['priority'],
|
|
'priority_score' => 170 + (int) $row['priority_score'],
|
|
'content_type' => $row['content_type'],
|
|
'content_id' => $row['content_id'],
|
|
'metric_snapshot' => [
|
|
'views' => $row['views'],
|
|
'starts' => $row['starts'],
|
|
'completions' => $row['completions'],
|
|
'completion_rate' => $row['completion_rate'],
|
|
],
|
|
'suggested_action' => $row['suggested_action'],
|
|
]);
|
|
}
|
|
|
|
foreach (array_slice($courseHealth['rows'], 0, 4) as $row) {
|
|
$recommendations->push([
|
|
'title' => sprintf('Review course "%s"', $row['title']),
|
|
'description' => sprintf('%s with a %.1f%% completion rate and %.1f%% average progress.', $row['issue'], $row['completion_rate'], $row['avg_progress']),
|
|
'reason' => 'Course progression data highlights where sequencing or positioning may be blocking learners.',
|
|
'priority' => $row['priority'],
|
|
'priority_score' => 160 + (int) $row['priority_score'],
|
|
'content_type' => $row['content_type'],
|
|
'content_id' => $row['content_id'],
|
|
'metric_snapshot' => [
|
|
'views' => $row['views'],
|
|
'starts' => $row['starts'],
|
|
'completions' => $row['completions'],
|
|
'avg_progress' => $row['avg_progress'],
|
|
],
|
|
'suggested_action' => $row['suggested_action'],
|
|
]);
|
|
}
|
|
|
|
foreach (array_slice($premiumInterest['rows'], 0, 4) as $row) {
|
|
$recommendations->push([
|
|
'title' => sprintf('Use "%s" as a premium signal', $row['title']),
|
|
'description' => sprintf('%s with %d preview views and %d upgrade clicks.', $row['issue'], $row['premium_preview_views'], $row['upgrade_clicks']),
|
|
'reason' => 'Premium preview behavior shows which topics can sell subscriptions or need better teaser copy.',
|
|
'priority' => $row['priority'],
|
|
'priority_score' => 150 + (int) $row['priority_score'],
|
|
'content_type' => $row['content_type'],
|
|
'content_id' => $row['content_id'],
|
|
'metric_snapshot' => [
|
|
'premium_preview_views' => $row['premium_preview_views'],
|
|
'upgrade_clicks' => $row['upgrade_clicks'],
|
|
'upgrade_rate' => $row['upgrade_rate'],
|
|
],
|
|
'suggested_action' => $row['suggested_action'],
|
|
]);
|
|
}
|
|
|
|
$rows = $recommendations
|
|
->sortByDesc('priority_score')
|
|
->take($limit)
|
|
->values()
|
|
->map(function (array $row): array {
|
|
unset($row['priority_score']);
|
|
|
|
return $row;
|
|
});
|
|
|
|
return [
|
|
'summary' => [
|
|
'total' => $rows->count(),
|
|
'high_priority' => $rows->where('priority', 'high')->count(),
|
|
'medium_priority' => $rows->where('priority', 'medium')->count(),
|
|
'low_priority' => $rows->where('priority', 'low')->count(),
|
|
],
|
|
'rows' => $rows->all(),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array{0: Carbon, 1: Carbon, 2: int}
|
|
*/
|
|
private function resolveFilters(array $filters): array
|
|
{
|
|
$from = ($filters['from'] ?? null) instanceof Carbon
|
|
? $filters['from']->copy()->startOfDay()
|
|
: Carbon::parse((string) ($filters['from'] ?? now()->subDays(29)->toDateString()))->startOfDay();
|
|
$to = ($filters['to'] ?? null) instanceof Carbon
|
|
? $filters['to']->copy()->endOfDay()
|
|
: Carbon::parse((string) ($filters['to'] ?? now()->toDateString()))->endOfDay();
|
|
$limit = max(1, min(50, (int) ($filters['limit'] ?? 25)));
|
|
|
|
return [$from, $to, $limit];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $filters
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function remember(string $suffix, array $filters, callable $callback): array
|
|
{
|
|
[$from, $to, $limit] = $this->resolveFilters($filters);
|
|
|
|
return Cache::remember(
|
|
sprintf('academy_analytics_%s:%s:%s:%d', $suffix, $from->toDateString(), $to->toDateString(), $limit),
|
|
now()->addMinutes(10),
|
|
fn (): array => $callback($from, $to, $limit),
|
|
);
|
|
}
|
|
|
|
private function searchQuery(Carbon $from, Carbon $to): Builder
|
|
{
|
|
return AcademySearchLog::query()
|
|
->whereBetween('created_at', [$from, $to])
|
|
->selectRaw('normalized_query, max(query) as query, count(*) as searches, avg(results_count) as avg_results_count, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, max(created_at) as last_searched_at, sum(case when is_logged_in = 1 then 1 else 0 end) as logged_in_searches, sum(case when is_subscriber = 1 then 1 else 0 end) as subscriber_searches')
|
|
->whereNotNull('normalized_query')
|
|
->groupBy('normalized_query');
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, array<string, int|float|string|null>>
|
|
*/
|
|
private function contentMetrics(Carbon $from, Carbon $to, string $contentType): Collection
|
|
{
|
|
return AcademyContentMetricDaily::query()
|
|
->whereBetween('date', [$from->toDateString(), $to->toDateString()])
|
|
->where('content_type', $contentType)
|
|
->selectRaw('content_type, content_id, sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(likes) as likes, sum(saves) as saves, sum(prompt_copies) as prompt_copies, sum(negative_prompt_copies) as negative_prompt_copies, sum(starts) as starts, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(premium_preview_views) as premium_preview_views, sum(search_clicks) as search_clicks, sum(popularity_score) as popularity_score')
|
|
->groupBy('content_type', 'content_id')
|
|
->get()
|
|
->map(function ($row) use ($contentType): array {
|
|
$contentId = (int) $row->content_id;
|
|
$uniqueVisitors = max(0, (int) ($row->unique_visitors ?? 0));
|
|
$promptCopies = max(0, (int) ($row->prompt_copies ?? 0));
|
|
$likes = max(0, (int) ($row->likes ?? 0));
|
|
$saves = max(0, (int) ($row->saves ?? 0));
|
|
$starts = max(0, (int) ($row->starts ?? 0));
|
|
$completions = max(0, (int) ($row->completions ?? 0));
|
|
$searchClicks = max(0, (int) ($row->search_clicks ?? 0));
|
|
$premiumPreviewViews = max(0, (int) ($row->premium_preview_views ?? 0));
|
|
$upgradeClicks = max(0, (int) ($row->upgrade_clicks ?? 0));
|
|
|
|
return [
|
|
'content_type' => $contentType,
|
|
'content_id' => $contentId,
|
|
'content_type_label' => (string) Str::of(str_replace('academy_', '', $contentType))->replace('_', ' ')->headline(),
|
|
'title' => $this->resolver->title($contentType, $contentId),
|
|
'views' => max(0, (int) ($row->views ?? 0)),
|
|
'unique_visitors' => $uniqueVisitors,
|
|
'engaged_views' => max(0, (int) ($row->engaged_views ?? 0)),
|
|
'likes' => $likes,
|
|
'saves' => $saves,
|
|
'prompt_copies' => $promptCopies,
|
|
'negative_prompt_copies' => max(0, (int) ($row->negative_prompt_copies ?? 0)),
|
|
'starts' => $starts,
|
|
'completions' => $completions,
|
|
'search_clicks' => $searchClicks,
|
|
'premium_preview_views' => $premiumPreviewViews,
|
|
'upgrade_clicks' => $upgradeClicks,
|
|
'popularity_score' => round((float) ($row->popularity_score ?? 0), 2),
|
|
'copy_rate' => $uniqueVisitors > 0 ? round(($promptCopies / $uniqueVisitors) * 100, 1) : 0.0,
|
|
'save_rate' => $uniqueVisitors > 0 ? round(($saves / $uniqueVisitors) * 100, 1) : 0.0,
|
|
'like_rate' => $uniqueVisitors > 0 ? round(($likes / $uniqueVisitors) * 100, 1) : 0.0,
|
|
'search_click_rate' => $uniqueVisitors > 0 ? round(($searchClicks / $uniqueVisitors) * 100, 1) : 0.0,
|
|
'start_rate' => $uniqueVisitors > 0 ? round(($starts / $uniqueVisitors) * 100, 1) : 0.0,
|
|
'completion_rate' => $starts > 0 ? round(($completions / $starts) * 100, 1) : 0.0,
|
|
'engagement_rate' => $uniqueVisitors > 0 ? round((((int) ($row->engaged_views ?? 0)) / $uniqueVisitors) * 100, 1) : 0.0,
|
|
'upgrade_rate' => $premiumPreviewViews > 0 ? round(($upgradeClicks / $premiumPreviewViews) * 100, 1) : 0.0,
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array{issue: string|null, priority: string, priority_score: int, suggested_action: string}
|
|
*/
|
|
private function classifySearchGap(int $searches, float $resultsCount, int $clicks, float $ctr, int $loggedInSearches, int $subscriberSearches): array
|
|
{
|
|
if ($resultsCount <= 0.4) {
|
|
return [
|
|
'issue' => 'Zero-result demand',
|
|
'priority' => $searches >= 3 || $subscriberSearches >= 2 ? 'high' : 'medium',
|
|
'priority_score' => 300 + $searches,
|
|
'suggested_action' => 'Create content for this topic',
|
|
];
|
|
}
|
|
|
|
if ($resultsCount > 0 && $clicks === 0) {
|
|
return [
|
|
'issue' => 'Results with no clicks',
|
|
'priority' => $searches >= 3 || $loggedInSearches >= 2 ? 'high' : 'medium',
|
|
'priority_score' => 240 + $searches,
|
|
'suggested_action' => 'Improve titles, excerpts, thumbnails, or relevance',
|
|
];
|
|
}
|
|
|
|
if ($searches >= 2 && $ctr < 10) {
|
|
return [
|
|
'issue' => 'Low click-through rate',
|
|
'priority' => 'medium',
|
|
'priority_score' => 180 + $searches,
|
|
'suggested_action' => 'Improve matching content or create better content',
|
|
];
|
|
}
|
|
|
|
if ($searches >= 2 && $ctr >= 40) {
|
|
return [
|
|
'issue' => 'High click-through topic',
|
|
'priority' => 'medium',
|
|
'priority_score' => 140 + $searches,
|
|
'suggested_action' => 'Consider expanding this topic',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'issue' => null,
|
|
'priority' => 'low',
|
|
'priority_score' => 0,
|
|
'suggested_action' => 'Monitor search intent',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int|float|string|null> $row
|
|
* @return array<string, int|string>|null
|
|
*/
|
|
private function classifyPromptInsight(array $row): ?array
|
|
{
|
|
if ((int) $row['upgrade_clicks'] >= 3 || ((float) $row['upgrade_rate'] >= 15 && (int) $row['premium_preview_views'] >= 5)) {
|
|
return [
|
|
'issue' => 'High upgrade interest',
|
|
'priority' => 'high',
|
|
'priority_score' => 300 + (int) $row['upgrade_clicks'],
|
|
'suggested_action' => 'Create premium pack or advanced lesson around this topic',
|
|
];
|
|
}
|
|
|
|
if ((int) $row['views'] >= 120 && (float) $row['copy_rate'] < 8) {
|
|
return [
|
|
'issue' => 'High views, low copies',
|
|
'priority' => 'medium',
|
|
'priority_score' => 230 + (int) $row['views'],
|
|
'suggested_action' => 'Improve prompt quality, preview image, title, or negative prompt',
|
|
];
|
|
}
|
|
|
|
if ((int) $row['views'] <= 30 && (int) $row['prompt_copies'] >= 3 && (float) $row['copy_rate'] >= 35) {
|
|
return [
|
|
'issue' => 'Low views, high copy rate',
|
|
'priority' => 'medium',
|
|
'priority_score' => 210 + (int) $row['prompt_copies'],
|
|
'suggested_action' => 'Feature this prompt, improve SEO, add to related content',
|
|
];
|
|
}
|
|
|
|
if ((int) $row['saves'] >= 5 && (int) $row['prompt_copies'] < (int) $row['saves']) {
|
|
return [
|
|
'issue' => 'High saves, low copies',
|
|
'priority' => 'medium',
|
|
'priority_score' => 190 + (int) $row['saves'],
|
|
'suggested_action' => 'Add examples, variations, or usage notes',
|
|
];
|
|
}
|
|
|
|
if ((int) $row['prompt_copies'] >= 8 && (float) $row['like_rate'] < 5) {
|
|
return [
|
|
'issue' => 'High copies, low likes',
|
|
'priority' => 'low',
|
|
'priority_score' => 160 + (int) $row['prompt_copies'],
|
|
'suggested_action' => 'Improve like/save UI visibility or ask for feedback',
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int|float|string|null> $row
|
|
* @return array<string, int|string>|null
|
|
*/
|
|
private function classifyLessonDropoff(array $row): ?array
|
|
{
|
|
if ((int) $row['starts'] >= 12 && (float) $row['completion_rate'] < 35) {
|
|
return [
|
|
'issue' => 'High starts, low completions',
|
|
'priority' => 'high',
|
|
'priority_score' => 300 + (int) $row['starts'],
|
|
'suggested_action' => 'Lesson may be too long, confusing, or missing examples',
|
|
];
|
|
}
|
|
|
|
if ((int) $row['views'] >= 80 && (float) $row['start_rate'] < 18) {
|
|
return [
|
|
'issue' => 'High views, low starts',
|
|
'priority' => 'medium',
|
|
'priority_score' => 230 + (int) $row['views'],
|
|
'suggested_action' => 'Improve lesson intro, title, excerpt, or call-to-action',
|
|
];
|
|
}
|
|
|
|
if ((int) $row['completions'] >= 8 && (int) $row['views'] <= 35) {
|
|
return [
|
|
'issue' => 'High completions, low views',
|
|
'priority' => 'medium',
|
|
'priority_score' => 200 + (int) $row['completions'],
|
|
'suggested_action' => 'Promote this lesson more',
|
|
];
|
|
}
|
|
|
|
if ((int) $row['upgrade_clicks'] >= 3 || ((float) $row['upgrade_rate'] >= 12 && (int) $row['premium_preview_views'] >= 5)) {
|
|
return [
|
|
'issue' => 'Upgrade interest',
|
|
'priority' => 'medium',
|
|
'priority_score' => 180 + (int) $row['upgrade_clicks'],
|
|
'suggested_action' => 'This lesson may be useful as a subscription conversion entry point',
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int|float|string|null> $row
|
|
* @return array<string, int|string>|null
|
|
*/
|
|
private function classifyCourseHealth(array $row): ?array
|
|
{
|
|
if ((int) $row['starts'] >= 10 && (float) $row['completion_rate'] < 35) {
|
|
return [
|
|
'issue' => 'Low course completion rate',
|
|
'priority' => 'high',
|
|
'priority_score' => 300 + (int) $row['starts'],
|
|
'suggested_action' => 'Add shorter lessons, move the strongest lesson earlier, or improve examples',
|
|
];
|
|
}
|
|
|
|
if ((int) $row['views'] >= 60 && (float) $row['start_rate'] < 18) {
|
|
return [
|
|
'issue' => 'Low course start rate',
|
|
'priority' => 'medium',
|
|
'priority_score' => 220 + (int) $row['views'],
|
|
'suggested_action' => 'Improve course landing page, cover image, or course positioning',
|
|
];
|
|
}
|
|
|
|
if ((int) $row['upgrade_clicks'] >= 3) {
|
|
return [
|
|
'issue' => 'Premium follow-up opportunity',
|
|
'priority' => 'medium',
|
|
'priority_score' => 190 + (int) $row['upgrade_clicks'],
|
|
'suggested_action' => 'Add a premium follow-up course around this topic',
|
|
];
|
|
}
|
|
|
|
if ((int) $row['completions'] >= 8 && (float) $row['completion_rate'] >= 65) {
|
|
return [
|
|
'issue' => 'Expansion candidate',
|
|
'priority' => 'medium',
|
|
'priority_score' => 170 + (int) $row['completions'],
|
|
'suggested_action' => 'Expand this course with advanced follow-up material',
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int|float|string|null> $row
|
|
* @return array<string, int|string>|null
|
|
*/
|
|
private function classifyPremiumInterest(array $row): ?array
|
|
{
|
|
if ((int) $row['upgrade_clicks'] >= 3) {
|
|
return [
|
|
'issue' => 'Strong premium candidate',
|
|
'priority' => 'high',
|
|
'priority_score' => 300 + (int) $row['upgrade_clicks'],
|
|
'suggested_action' => 'Create advanced premium content around this topic',
|
|
];
|
|
}
|
|
|
|
if ((int) $row['premium_preview_views'] >= 15 && (int) $row['upgrade_clicks'] <= 1) {
|
|
return [
|
|
'issue' => 'Weak premium teaser',
|
|
'priority' => 'medium',
|
|
'priority_score' => 190 + (int) $row['premium_preview_views'],
|
|
'suggested_action' => 'Improve teaser copy, preview images, or value proposition',
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, array<string, mixed>> $rows
|
|
* @return Collection<int, array<string, mixed>>
|
|
*/
|
|
private function dedupeByKey(Collection $rows, string $key): Collection
|
|
{
|
|
$seen = [];
|
|
|
|
return $rows->filter(function (array $row) use (&$seen, $key): bool {
|
|
$value = (string) ($row[$key] ?? '');
|
|
|
|
if ($value === '' || isset($seen[$value])) {
|
|
return false;
|
|
}
|
|
|
|
$seen[$value] = true;
|
|
|
|
return true;
|
|
});
|
|
}
|
|
}
|