lthn.io/app/Core/Search/Suggestions/SearchSuggestions.php
Claude 41a90cbff8
feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.

Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 17:17:42 +01:00

512 lines
16 KiB
PHP

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Search\Suggestions;
use Core\Mod\Uptelligence\Models\Asset;
use Core\Mod\Uptelligence\Models\Pattern;
use Core\Search\Analytics\SearchAnalytics;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Search Suggestions and Autocomplete Service.
*
* Provides type-ahead suggestions based on:
* - Popular search queries from search analytics
* - Recent searches (per user or session)
* - Prefix matching for instant suggestions
* - Content-based suggestions from searchable items
*
* Features:
* - Real-time prefix matching as user types
* - Popularity-weighted suggestions
* - User-specific recent searches
* - Configurable suggestion sources
* - Privacy-aware (respects exclude patterns)
*
* Configuration in config/search.php:
* 'suggestions' => [
* 'enabled' => true,
* 'max_suggestions' => 10,
* 'min_query_length' => 2,
* 'cache_ttl' => 300,
* 'track_recent' => true,
* 'max_recent' => 20,
* 'sources' => ['popular', 'recent', 'content'],
* ]
*
* @see SearchAnalytics For search tracking integration
*/
class SearchSuggestions
{
/**
* Table name for search analytics.
*/
protected const ANALYTICS_TABLE = 'search_analytics';
/**
* Cache prefix for suggestions.
*/
protected const CACHE_PREFIX = 'search_suggestions:';
/**
* Maximum suggestions to return.
*/
protected int $maxSuggestions;
/**
* Minimum query length for suggestions.
*/
protected int $minQueryLength;
/**
* Cache TTL in seconds.
*/
protected int $cacheTtl;
/**
* Whether to track recent searches.
*/
protected bool $trackRecent;
/**
* Maximum recent searches to store.
*/
protected int $maxRecent;
/**
* Enabled suggestion sources.
*
* @var array<string>
*/
protected array $sources;
/**
* Patterns to exclude from suggestions (for privacy).
*
* @var array<string>
*/
protected array $excludePatterns;
/**
* Whether the analytics table exists.
*/
protected ?bool $tableExists = null;
public function __construct()
{
$this->maxSuggestions = config('search.suggestions.max_suggestions', 10);
$this->minQueryLength = config('search.suggestions.min_query_length', 2);
$this->cacheTtl = config('search.suggestions.cache_ttl', 300);
$this->trackRecent = config('search.suggestions.track_recent', true);
$this->maxRecent = config('search.suggestions.max_recent', 20);
$this->sources = config('search.suggestions.sources', ['popular', 'recent', 'content']);
$this->excludePatterns = config('search.analytics.exclude_patterns', [
'password',
'secret',
'token',
'key',
'credit',
'ssn',
]);
}
/**
* Check if suggestions are enabled.
*/
public function isEnabled(): bool
{
return config('search.suggestions.enabled', true);
}
/**
* Get suggestions for a partial query.
*
* @param string $query The partial search query
* @param int|null $limit Maximum suggestions (null uses config default)
* @param array<string>|null $sources Sources to use (null uses config default)
* @return Collection<int, array{text: string, type: string, score: float, metadata: array}>
*/
public function suggest(string $query, ?int $limit = null, ?array $sources = null): Collection
{
$query = strtolower(trim($query));
$limit = $limit ?? $this->maxSuggestions;
$sources = $sources ?? $this->sources;
if (! $this->isEnabled() || strlen($query) < $this->minQueryLength) {
return collect();
}
// Check for excluded patterns
if ($this->shouldExclude($query)) {
return collect();
}
$suggestions = collect();
// Gather suggestions from each enabled source
if (in_array('popular', $sources)) {
$suggestions = $suggestions->merge($this->getPopularSuggestions($query, $limit));
}
if (in_array('recent', $sources)) {
$suggestions = $suggestions->merge($this->getRecentSuggestions($query, $limit));
}
if (in_array('content', $sources)) {
$suggestions = $suggestions->merge($this->getContentSuggestions($query, $limit));
}
// Deduplicate, sort by score, and limit
return $suggestions
->unique('text')
->sortByDesc('score')
->take($limit)
->values();
}
/**
* Get popular query suggestions based on search analytics.
*
* @param string $prefix The query prefix to match
* @param int $limit Maximum suggestions
* @return Collection<int, array{text: string, type: string, score: float, metadata: array}>
*/
public function getPopularSuggestions(string $prefix, int $limit = 10): Collection
{
if (! $this->analyticsTableExists()) {
return collect();
}
$cacheKey = self::CACHE_PREFIX."popular:{$prefix}:{$limit}";
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($prefix, $limit) {
$escaped = $this->escapeLikeQuery($prefix);
return DB::table(self::ANALYTICS_TABLE)
->select('query', DB::raw('COUNT(*) as search_count'))
->where('query', 'like', "{$escaped}%")
->where('result_count', '>', 0) // Only suggest queries that had results
->where('created_at', '>=', now()->subDays(30))
->groupBy('query')
->orderByDesc('search_count')
->limit($limit * 2) // Get more to account for filtering
->get()
->filter(fn ($row) => ! $this->shouldExclude($row->query))
->take($limit)
->map(fn ($row) => [
'text' => $row->query,
'type' => 'popular',
'score' => $this->calculatePopularityScore($row->search_count),
'metadata' => [
'search_count' => (int) $row->search_count,
],
])
->values();
});
}
/**
* Get recent search suggestions for the current user/session.
*
* @param string $prefix The query prefix to match
* @param int $limit Maximum suggestions
* @return Collection<int, array{text: string, type: string, score: float, metadata: array}>
*/
public function getRecentSuggestions(string $prefix, int $limit = 10): Collection
{
if (! $this->trackRecent) {
return collect();
}
$recentSearches = $this->getRecentSearches();
return $recentSearches
->filter(fn ($search) => str_starts_with(strtolower($search['query']), $prefix))
->filter(fn ($search) => ! $this->shouldExclude($search['query']))
->take($limit)
->map(fn ($search, $index) => [
'text' => $search['query'],
'type' => 'recent',
'score' => 100 - $index, // More recent = higher score
'metadata' => [
'searched_at' => $search['searched_at'],
],
])
->values();
}
/**
* Get content-based suggestions from searchable items.
*
* This searches titles/names of searchable content for prefix matches.
*
* @param string $prefix The query prefix to match
* @param int $limit Maximum suggestions
* @return Collection<int, array{text: string, type: string, score: float, metadata: array}>
*/
public function getContentSuggestions(string $prefix, int $limit = 10): Collection
{
$suggestions = collect();
$escaped = $this->escapeLikeQuery($prefix);
// Search patterns if available
if (class_exists(Pattern::class)) {
try {
$patterns = Pattern::where('name', 'like', "{$escaped}%")
->limit($limit)
->pluck('name')
->map(fn ($name) => [
'text' => strtolower($name),
'type' => 'content',
'score' => 50,
'metadata' => ['source' => 'pattern'],
]);
$suggestions = $suggestions->merge($patterns);
} catch (\Exception $e) {
// Ignore errors
}
}
// Search assets if available
if (class_exists(Asset::class)) {
try {
$assets = Asset::where('name', 'like', "{$escaped}%")
->limit($limit)
->pluck('name')
->map(fn ($name) => [
'text' => strtolower($name),
'type' => 'content',
'score' => 50,
'metadata' => ['source' => 'asset'],
]);
$suggestions = $suggestions->merge($assets);
} catch (\Exception $e) {
// Ignore errors
}
}
return $suggestions->take($limit)->values();
}
/**
* Record a search query in the user's recent searches.
*
* @param string $query The search query to record
*/
public function recordRecentSearch(string $query): void
{
if (! $this->trackRecent) {
return;
}
$query = trim($query);
if (empty($query) || $this->shouldExclude($query)) {
return;
}
$key = $this->getRecentSearchesKey();
$recent = $this->getRecentSearches();
// Remove if already exists (will be re-added at top)
$recent = $recent->filter(fn ($s) => strtolower($s['query']) !== strtolower($query));
// Add to beginning
$recent = $recent->prepend([
'query' => $query,
'searched_at' => now()->toIso8601String(),
]);
// Trim to max size
$recent = $recent->take($this->maxRecent);
Cache::put($key, $recent->toArray(), now()->addDays(30));
}
/**
* Get the user's recent searches.
*
* @return Collection<int, array{query: string, searched_at: string}>
*/
public function getRecentSearches(): Collection
{
$key = $this->getRecentSearchesKey();
return collect(Cache::get($key, []));
}
/**
* Clear the user's recent searches.
*/
public function clearRecentSearches(): void
{
Cache::forget($this->getRecentSearchesKey());
}
/**
* Get trending searches (queries with increasing popularity).
*
* @param int $limit Maximum trending queries
* @param int $days Days to analyze
* @return Collection<int, array{query: string, current_count: int, previous_count: int, growth: float}>
*/
public function getTrendingSuggestions(int $limit = 10, int $days = 7): Collection
{
if (! $this->analyticsTableExists()) {
return collect();
}
$cacheKey = self::CACHE_PREFIX."trending:{$limit}:{$days}";
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($limit, $days) {
$midpoint = now()->subDays($days / 2);
// Get counts for recent period
$recent = DB::table(self::ANALYTICS_TABLE)
->select('query', DB::raw('COUNT(*) as count'))
->where('created_at', '>=', $midpoint)
->where('result_count', '>', 0)
->groupBy('query')
->pluck('count', 'query');
// Get counts for earlier period
$earlier = DB::table(self::ANALYTICS_TABLE)
->select('query', DB::raw('COUNT(*) as count'))
->where('created_at', '>=', now()->subDays($days))
->where('created_at', '<', $midpoint)
->where('result_count', '>', 0)
->groupBy('query')
->pluck('count', 'query');
// Calculate growth
return $recent->map(function ($count, $query) use ($earlier) {
$previousCount = $earlier->get($query, 0);
$growth = $previousCount > 0
? (($count - $previousCount) / $previousCount) * 100
: ($count > 1 ? 100 : 0);
return [
'query' => $query,
'current_count' => (int) $count,
'previous_count' => (int) $previousCount,
'growth' => round($growth, 2),
];
})
->filter(fn ($item) => $item['growth'] > 0 && ! $this->shouldExclude($item['query']))
->sortByDesc('growth')
->take($limit)
->values();
});
}
/**
* Get the cache key for the current user's recent searches.
*/
protected function getRecentSearchesKey(): string
{
$userId = auth()->id();
if ($userId) {
return self::CACHE_PREFIX."recent:user:{$userId}";
}
// Fall back to session ID for guests
return self::CACHE_PREFIX.'recent:session:'.session()->getId();
}
/**
* Calculate popularity score based on search count.
*
* Uses logarithmic scaling to prevent extremely popular queries
* from dominating all suggestions.
*/
protected function calculatePopularityScore(int $searchCount): float
{
// Log scale: 1 search = 10, 10 searches = 20, 100 searches = 30, etc.
return 10 * (1 + log10(max(1, $searchCount)));
}
/**
* Check if a query should be excluded from suggestions.
*/
protected function shouldExclude(string $query): bool
{
$lowerQuery = strtolower($query);
foreach ($this->excludePatterns as $pattern) {
if (str_contains($lowerQuery, strtolower($pattern))) {
return true;
}
}
return false;
}
/**
* Escape special LIKE wildcards in query.
*/
protected function escapeLikeQuery(string $query): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query);
}
/**
* Check if the analytics table exists (cached).
*/
protected function analyticsTableExists(): bool
{
if ($this->tableExists !== null) {
return $this->tableExists;
}
$this->tableExists = Cache::remember(
self::CACHE_PREFIX.'table_exists',
300,
fn () => Schema::hasTable(self::ANALYTICS_TABLE)
);
return $this->tableExists;
}
/**
* Invalidate all suggestion caches.
*/
public function clearCache(): void
{
// Clear popular suggestions cache
Cache::forget(self::CACHE_PREFIX.'table_exists');
// Note: Individual prefix caches will expire naturally
// For full cache clear, use cache:clear artisan command
}
/**
* Get configuration for the suggestion system.
*
* @return array{enabled: bool, max_suggestions: int, min_query_length: int, sources: array}
*/
public function getConfig(): array
{
return [
'enabled' => $this->isEnabled(),
'max_suggestions' => $this->maxSuggestions,
'min_query_length' => $this->minQueryLength,
'sources' => $this->sources,
'track_recent' => $this->trackRecent,
'cache_ttl' => $this->cacheTtl,
];
}
}