Files
timebank-cc-public/app/Helpers/SearchOptimizationHelper.php
T

565 lines
20 KiB
PHP

<?php
namespace App\Helpers;
use App\Models\Category;
use App\Models\SearchMetric;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class SearchOptimizationHelper
{
private const CACHE_PREFIX = 'search_optimization_';
private const DEFAULT_TTL = 3600; // 1 hour
/**
* Build optimized category hierarchy with caching
*/
public static function getCategoryHierarchy(string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
$cacheKey = self::CACHE_PREFIX . "category_hierarchy_{$locale}";
return Cache::remember($cacheKey, self::DEFAULT_TTL, function () use ($locale) {
$categories = Category::where('type', 'App\Models\Tag')
->with(['translations' => function ($query) use ($locale) {
$query->where('locale', $locale);
}])
->get();
return self::buildHierarchyRecursive($categories);
});
}
/**
* Get expanded category IDs with caching
*/
public static function getExpandedCategoryIds(array $categoryIds): array
{
$cacheKey = self::CACHE_PREFIX . 'expanded_' . md5(serialize($categoryIds));
return Cache::remember($cacheKey, self::DEFAULT_TTL, function () use ($categoryIds) {
$allIds = [];
foreach ($categoryIds as $categoryId) {
if (is_numeric($categoryId) && $categoryId > 0) {
$descendants = self::getCategoryDescendants((int)$categoryId);
$allIds = array_merge($allIds, $descendants);
}
}
return array_values(array_unique($allIds));
});
}
/**
* Get category names by IDs with caching
*/
public static function getCategoryNamesByIds(array $categoryIds, string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
$cacheKey = self::CACHE_PREFIX . "names_{$locale}_" . md5(serialize($categoryIds));
return Cache::remember($cacheKey, 300, function () use ($categoryIds, $locale) {
$categories = Category::whereIn('id', $categoryIds)
->with(['translations' => function ($query) use ($locale) {
$query->where('locale', $locale);
}])
->get();
return $categories->map(function ($category) {
return $category->translation ? $category->translation->name : null;
})->filter()->values()->toArray();
});
}
/**
* Clear all search-related caches using app locales
*/
public static function clearSearchCaches(): void
{
// Use your supported locales
$locales = ['en', 'nl', 'fr', 'es', 'de'];
foreach ($locales as $locale) {
Cache::forget(self::CACHE_PREFIX . "category_hierarchy_{$locale}");
}
// Clear pattern-based cache keys
if (Cache::getStore() instanceof \Illuminate\Cache\RedisStore) {
$cacheKeys = Cache::getRedis()->keys(self::CACHE_PREFIX . '*');
if (!empty($cacheKeys)) {
Cache::getRedis()->del($cacheKeys);
}
}
Log::info('Search caches cleared');
}
/**
* Build search query suggestions based on category names
*/
public static function buildSearchSuggestions(array $categoryNames, string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
$suggestions = [];
foreach ($categoryNames as $categoryName) {
// Add exact match
$suggestions[] = [
'text' => $categoryName,
'weight' => 10,
'input' => [$categoryName],
];
// Add partial matches
$words = explode(' ', $categoryName);
if (count($words) > 1) {
foreach ($words as $word) {
if (strlen($word) > 2) {
$suggestions[] = [
'text' => $word,
'weight' => 5,
'input' => [$word],
];
}
}
}
}
return array_unique($suggestions, SORT_REGULAR);
}
/**
* Optimize search results by applying relevance scoring including location
*/
public static function optimizeSearchResults(array $results, array $searchTerms = [], array $userLocation = []): array
{
$optimized = [];
foreach ($results as $result) {
$score = $result['score'] ?? 1;
// Boost based on model type
$modelBoost = self::getModelTypeBoost($result['model'] ?? '');
$score *= $modelBoost;
// Boost based on highlight quality
$highlightBoost = self::getHighlightBoost($result['highlight'] ?? []);
$score *= $highlightBoost;
// Boost based on data completeness
$completenessBoost = self::getCompletenessBoost($result);
$score *= $completenessBoost;
// Boost based on recency
$recencyBoost = self::getRecencyBoost($result);
$score *= $recencyBoost;
// Boost based on location proximity
$locationBoost = self::getLocationBoost($result, $userLocation);
$score *= $locationBoost;
$result['optimized_score'] = $score;
$optimized[] = $result;
}
// Sort by optimized score primarily, with location as a tie-breaker
usort($optimized, function ($a, $b) {
// Primary sort: optimized score (higher scores first)
$aScore = $a['optimized_score'] ?? 0;
$bScore = $b['optimized_score'] ?? 0;
$scoreDiff = $bScore <=> $aScore;
if ($scoreDiff !== 0) {
return $scoreDiff;
}
// Secondary sort: location distance (closer locations first)
$aDistance = $a['location_proximity']['distance'] ?? 9;
$bDistance = $b['location_proximity']['distance'] ?? 9;
return $aDistance <=> $bDistance;
});
return $optimized;
}
/**
* Resolve the authenticated user's profile type label without storing any identifier.
*/
private static function resolveUserType(): string
{
if (!auth()->check()) {
return 'guest';
}
$profileType = getActiveProfileType();
return match (true) {
$profileType === 'Admin' => 'admin',
$profileType === 'Organization' => 'organization',
$profileType === 'Bank' => 'bank',
$profileType === 'User' => 'user',
default => 'authenticated',
};
}
/**
* Generate search analytics data
*/
public static function trackSearchAnalytics(array $categoryIds, int $totalResults, float $executionTime, string $searchString = ''): void
{
self::writeSearchMetricEntry([
'search_string' => $searchString,
'category_ids' => $categoryIds,
'total_results' => $totalResults,
'execution_time' => $executionTime,
'locale' => app()->getLocale(),
'user_type' => self::resolveUserType(),
]);
}
/**
* Get search performance metrics from the current month's log file
*/
public static function getSearchMetrics(?\Carbon\Carbon $since = null): array
{
$recentSearches = self::readCurrentMonthEntries(50, $since);
if (empty($recentSearches)) {
return [
'total_searches' => 0,
'avg_results' => 0,
'avg_execution_time' => 0,
'popular_categories' => [],
'popular_category_colors' => [],
];
}
$totalSearches = count($recentSearches);
$totalResults = array_sum(array_column($recentSearches, 'total_results'));
$totalExecutionTime = array_sum(array_column($recentSearches, 'execution_time'));
$cutoff24h = now()->subHours(24);
$searchesLast24h = count(array_filter($recentSearches, fn($s) => isset($s['timestamp']) && \Carbon\Carbon::parse($s['timestamp'])->gte($cutoff24h)));
// Avg searches per day based on the actual span between oldest and newest entry
$timestamps = array_filter(array_column($recentSearches, 'timestamp'));
$spanDays = 1;
if (count($timestamps) > 1) {
$oldest = \Carbon\Carbon::parse(min($timestamps));
$newest = \Carbon\Carbon::parse(max($timestamps));
$spanDays = max(1, (int) ($oldest->diffInDays($newest, true)) + 1);
}
$avgSearchesPerDay = round($totalSearches / $spanDays, 1);
// Get popular categories — count by ID, then resolve names and colors
$allCategoryIds = [];
foreach ($recentSearches as $search) {
$allCategoryIds = array_merge($allCategoryIds, $search['category_ids'] ?? []);
}
$popularByCount = array_count_values($allCategoryIds);
arsort($popularByCount);
$topIds = array_keys(array_slice($popularByCount, 0, 5, true));
$tagMap = [];
if (!empty($topIds)) {
$locale = app()->getLocale();
$categories = Category::whereIn('id', $topIds)
->with(['translations' => fn($q) => $q->where('locale', $locale)])
->get()
->keyBy('id');
foreach ($topIds as $id) {
$cat = $categories->get($id);
$tagMap[$id] = [
'name' => $cat?->translation?->name ?? $cat?->name ?? $id,
'color' => $cat?->relatedColor ?? 'gray',
];
}
}
$popularCategories = [];
$popularCategoryColors = [];
$popularCategoryIds = [];
foreach (array_slice($popularByCount, 0, 5, true) as $id => $count) {
$name = $tagMap[$id]['name'] ?? $id;
$popularCategories[$name] = $count;
$popularCategoryColors[$name] = $tagMap[$id]['color'] ?? 'gray';
$popularCategoryIds[$name] = $id;
}
return [
'total_searches' => $totalSearches,
'searches_last_24h' => $searchesLast24h,
'avg_results' => $totalSearches > 0 ? round($totalResults / $totalSearches, 2) : 0,
'avg_execution_time' => $totalSearches > 0 ? round($totalExecutionTime / $totalSearches, 4) : 0,
'avg_searches_per_day' => $avgSearchesPerDay,
'popular_categories' => $popularCategories,
'popular_category_colors' => $popularCategoryColors,
'popular_category_ids' => $popularCategoryIds,
];
}
/**
* Write a single search metric entry to the database
*/
private static function writeSearchMetricEntry(array $data): void
{
$locationHierarchy = $data['location_hierarchy'] ?? [];
try {
SearchMetric::create([
'searched_at' => now(),
'search_string' => $data['search_string'] ?? '',
'category_ids' => $data['category_ids'] ?? [],
'total_results' => $data['total_results'] ?? 0,
'execution_time' => $data['execution_time'] ?? 0.0,
'locale' => $data['locale'] ?? '',
'user_type' => $data['user_type'] ?? 'guest',
'country_id' => $locationHierarchy['country_id'] ?? null,
'division_id' => $locationHierarchy['division_id'] ?? null,
'city_id' => $locationHierarchy['city_id'] ?? null,
'district_id' => $locationHierarchy['district_id'] ?? null,
]);
} catch (\Exception $e) {
Log::warning('Failed to write search metric entry: ' . $e->getMessage());
}
}
/**
* Read the most recent entries from the past 12 months from the database
*/
public static function readCurrentMonthEntries(int $limit = 50, ?\Carbon\Carbon $since = null): array
{
$since ??= now()->subMonths(12)->startOfMonth();
try {
return SearchMetric::where('searched_at', '>=', $since)
->orderBy('searched_at', 'desc')
->limit($limit)
->get()
->map(fn($row) => [
'timestamp' => $row->searched_at->toISOString(),
'search_string' => $row->search_string,
'category_ids' => $row->category_ids,
'total_results' => $row->total_results,
'execution_time' => $row->execution_time,
'locale' => $row->locale,
'user_type' => $row->user_type,
'country_id' => $row->country_id,
'division_id' => $row->division_id,
'city_id' => $row->city_id,
'district_id' => $row->district_id,
])
->toArray();
} catch (\Exception $e) {
Log::warning('Failed to read search metrics from database: ' . $e->getMessage());
return [];
}
}
/**
* Build hierarchy recursively
*/
private static function buildHierarchyRecursive($categories, $parentId = null): array
{
$hierarchy = [];
foreach ($categories as $category) {
if ($category->parent_id == $parentId) {
$categoryData = [
'id' => $category->id,
'name' => $category->translation->name ?? $category->name,
'color' => $category->relatedColor,
'parent_id' => $category->parent_id,
'children' => self::buildHierarchyRecursive($categories, $category->id)
];
$hierarchy[] = $categoryData;
}
}
return $hierarchy;
}
/**
* Get category descendants with caching
*/
private static function getCategoryDescendants(int $categoryId): array
{
$cacheKey = self::CACHE_PREFIX . "descendants_{$categoryId}";
return Cache::remember($cacheKey, self::DEFAULT_TTL, function () use ($categoryId) {
$category = Category::find($categoryId);
if (!$category) {
return [$categoryId];
}
try {
$descendants = $category->descendants()->pluck('id')->toArray();
return array_merge([$categoryId], array_map('intval', $descendants));
} catch (\Exception $e) {
Log::warning("Failed to get descendants for category {$categoryId}: " . $e->getMessage());
return [$categoryId];
}
});
}
/**
* Get model type boost factor using timebank-cc configuration
*/
private static function getModelTypeBoost(string $modelClass): float
{
$type = strtolower(class_basename($modelClass));
return timebank_config('main_search_bar.boosted_models.' . $type, 1.0);
}
/**
* Get highlight quality boost
*/
private static function getHighlightBoost(array $highlights): float
{
if (empty($highlights)) {
return 1.0;
}
$totalHighlights = 0;
foreach ($highlights as $fieldHighlights) {
$totalHighlights += count($fieldHighlights);
}
// More highlights = higher relevance
return 1.0 + (min($totalHighlights, 10) * 0.05);
}
/**
* Get data completeness boost
*/
private static function getCompletenessBoost(array $result): float
{
$completenessFactors = 0;
$totalFactors = 0;
// Check various completeness factors
$checks = [
'about' => !empty($result['about'] ?? ''),
'about_short' => !empty($result['about_short'] ?? ''),
'subtitle' => !empty($result['subtitle'] ?? ''),
'photo' => !empty($result['photo'] ?? ''),
'location' => !empty($result['location'] ?? ''),
'category' => !empty($result['category'] ?? ''),
];
foreach ($checks as $factor => $isPresent) {
$totalFactors++;
if ($isPresent) {
$completenessFactors++;
}
}
if ($totalFactors === 0) {
return 1.0;
}
$completenessRatio = $completenessFactors / $totalFactors;
return 1.0 + ($completenessRatio * 0.2); // Up to 20% boost
}
/**
* Get location-based boost based on proximity to user
*/
private static function getLocationBoost(array $result, array $userLocation): float
{
if (empty($userLocation) || !isset($result['location_proximity'])) {
return 1.0;
}
$proximity = $result['location_proximity'];
// Location proximity boost factors
$locationBoosts = [
'same_district' => 1.1,
'same_city' => 1.05,
'same_division' => 1.02,
'same_country' => 1.0,
'different_country' => 1.0,
'no_location' => 1.0,
'unknown' => 1.0,
'error' => 1.0,
];
return $locationBoosts[$proximity['level'] ?? 'unknown'] ?? 1.0;
}
/**
* Get recency boost based on creation/update time
*/
private static function getRecencyBoost(array $result): float
{
// This would need to be implemented based on your specific timestamp fields
// For now, return neutral boost
return 1.0;
}
/**
* Generate location-aware search analytics
*/
public static function trackLocationSearchAnalytics(array $categoryIds, int $totalResults, float $executionTime, array $locationHierarchy = [], string $searchString = ''): void
{
self::writeSearchMetricEntry([
'search_string' => $searchString,
'category_ids' => $categoryIds,
'total_results' => $totalResults,
'execution_time' => $executionTime,
'locale' => app()->getLocale(),
'user_type' => self::resolveUserType(),
'location_hierarchy' => [
'country_id' => $locationHierarchy['country_id'] ?? null,
'division_id' => $locationHierarchy['division_id'] ?? null,
'city_id' => $locationHierarchy['city_id'] ?? null,
'district_id' => $locationHierarchy['district_id'] ?? null,
],
]);
}
/**
* Validate search parameters using timebank-cc configuration
*/
public static function validateSearchParams(array $params): array
{
$validated = [];
// Validate category IDs
if (isset($params['category_ids']) && is_array($params['category_ids'])) {
$validated['category_ids'] = array_filter($params['category_ids'], function ($id) {
return is_numeric($id) && $id > 0;
});
}
// Validate locale using your base language and supported locales
if (isset($params['locale'])) {
// Use your supported locales (en, nl, fr, es, de from your config)
$availableLocales = ['en', 'nl', 'fr', 'es', 'de'];
$validated['locale'] = in_array($params['locale'], $availableLocales)
? $params['locale']
: timebank_config('base_language', 'en');
}
// Validate limit using your max_results
if (isset($params['limit'])) {
$maxResults = timebank_config('main_search_bar.search.max_results', 50);
$validated['limit'] = max(1, min((int)$params['limit'], $maxResults));
}
return $validated;
}
/**
* Build cache key for search results compatible with timebank-cc naming
*/
public static function buildSearchCacheKey(array $params): string
{
ksort($params); // Ensure consistent ordering
return 'category_search_results_' . md5(serialize($params));
}
}