diff --git a/app/Commands/KnowledgeSearchCommand.php b/app/Commands/KnowledgeSearchCommand.php index e764f7e..7e6278a 100644 --- a/app/Commands/KnowledgeSearchCommand.php +++ b/app/Commands/KnowledgeSearchCommand.php @@ -4,8 +4,9 @@ namespace App\Commands; +use App\Enums\SearchTier; use App\Services\EntryMetadataService; -use App\Services\QdrantService; +use App\Services\TieredSearchService; use LaravelZero\Framework\Commands\Command; class KnowledgeSearchCommand extends Command @@ -22,14 +23,15 @@ class KnowledgeSearchCommand extends Command {--status= : Filter by status} {--limit=20 : Maximum number of results} {--semantic : Use semantic search if available} - {--include-superseded : Include superseded entries in results}'; + {--include-superseded : Include superseded entries in results} + {--tier= : Force searching a specific tier (working, recent, structured, archive)}'; /** * @var string */ protected $description = 'Search knowledge entries by keyword, tag, or category'; - public function handle(QdrantService $qdrant, EntryMetadataService $metadata): int + public function handle(TieredSearchService $tieredSearch, EntryMetadataService $metadata): int { $query = $this->argument('query'); $tag = $this->option('tag'); @@ -40,6 +42,7 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i $limit = (int) $this->option('limit'); $this->option('semantic'); $includeSuperseded = (bool) $this->option('include-superseded'); + $tierOption = $this->option('tier'); // Require at least one search parameter for entries if ($query === null && $tag === null && $category === null && $module === null && $priority === null && $status === null) { @@ -48,7 +51,19 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i return self::FAILURE; } - // Build filters for Qdrant search + // Validate tier option + $forceTier = null; + if (is_string($tierOption) && $tierOption !== '') { + $forceTier = SearchTier::tryFrom($tierOption); + if ($forceTier === null) { + $validTiers = implode(', ', array_map(fn (SearchTier $t): string => $t->value, SearchTier::cases())); + $this->error("Invalid tier '{$tierOption}'. Valid tiers: {$validTiers}"); + + return self::FAILURE; + } + } + + // Build filters for search $filters = array_filter([ 'tag' => is_string($tag) ? $tag : null, 'category' => is_string($category) ? $category : null, @@ -61,9 +76,9 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i $filters['include_superseded'] = true; } - // Use Qdrant for semantic search (always) + // Use tiered search $searchQuery = is_string($query) ? $query : ''; - $results = $qdrant->search($searchQuery, $filters, $limit); + $results = $tieredSearch->search($searchQuery, $filters, $limit, $forceTier); if ($results->isEmpty()) { $this->line('No entries found.'); @@ -85,12 +100,24 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i $content = $entry['content'] ?? ''; $score = $entry['score'] ?? 0.0; $supersededBy = $entry['superseded_by'] ?? null; + $tierLabel = $entry['tier_label'] ?? null; + $tieredScore = $entry['tiered_score'] ?? null; $isStale = $metadata->isStale($entry); $effectiveConfidence = $metadata->calculateEffectiveConfidence($entry); $confidenceLevel = $metadata->confidenceLevel($effectiveConfidence); - $titleLine = "[{$id}] {$title} (score: ".number_format($score, 2).')'; + $scoreDisplay = 'score: '.number_format($score, 2); + if ($tieredScore !== null) { + $scoreDisplay .= ' | ranked: '.number_format((float) $tieredScore, 2); + } + + $tierDisplay = ''; + if (is_string($tierLabel)) { + $tierDisplay = " [{$tierLabel}]"; + } + + $titleLine = "[{$id}] {$title} ({$scoreDisplay}){$tierDisplay}"; if ($supersededBy !== null) { $titleLine .= ' [SUPERSEDED]'; } diff --git a/app/Enums/SearchTier.php b/app/Enums/SearchTier.php new file mode 100644 index 0000000..ec7d78b --- /dev/null +++ b/app/Enums/SearchTier.php @@ -0,0 +1,36 @@ + 'Working Context', + self::Recent => 'Recent (14 days)', + self::Structured => 'Structured Storage', + self::Archive => 'Archive', + }; + } + + /** + * @return array + */ + public static function searchOrder(): array + { + return [ + self::Working, + self::Recent, + self::Structured, + self::Archive, + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 74c980d..814d8b9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,7 @@ use App\Services\DailyLogService; use App\Services\DeletionTracker; use App\Services\EnhancementQueueService; +use App\Services\EntryMetadataService; use App\Services\HealthCheckService; use App\Services\KnowledgeCacheService; use App\Services\KnowledgePathService; @@ -15,6 +16,7 @@ use App\Services\QdrantService; use App\Services\RuntimeEnvironment; use App\Services\StubEmbeddingService; +use App\Services\TieredSearchService; use App\Services\WriteGateService; use Illuminate\Support\ServiceProvider; @@ -155,6 +157,12 @@ public function register(): void cacheService: $app->make(KnowledgeCacheService::class) )); + // Tiered search service + $this->app->singleton(TieredSearchService::class, fn ($app): \App\Services\TieredSearchService => new TieredSearchService( + $app->make(QdrantService::class), + $app->make(EntryMetadataService::class), + )); + // Odin sync service $this->app->singleton(OdinSyncService::class, fn ($app): \App\Services\OdinSyncService => new OdinSyncService( $app->make(KnowledgePathService::class) diff --git a/app/Services/TieredSearchService.php b/app/Services/TieredSearchService.php new file mode 100644 index 0000000..3ab7d98 --- /dev/null +++ b/app/Services/TieredSearchService.php @@ -0,0 +1,222 @@ + $filters + * @return Collection> + */ + public function search( + string $query, + array $filters = [], + int $limit = 20, + ?SearchTier $forceTier = null, + string $project = 'default', + ): Collection { + if ($forceTier !== null) { + return $this->searchTier($query, $filters, $limit, $forceTier, $project); + } + + foreach (SearchTier::searchOrder() as $tier) { + $results = $this->searchTier($query, $filters, $limit, $tier, $project); + + if ($this->hasConfidentMatches($results)) { + return $results; + } + } + + // No confident matches at any tier - return all results merged and ranked + return $this->searchAllTiers($query, $filters, $limit, $project); + } + + /** + * Search a specific tier and return results with tier labels. + * + * @param array $filters + * @return Collection> + */ + public function searchTier( + string $query, + array $filters, + int $limit, + SearchTier $tier, + string $project = 'default', + ): Collection { + $tierFilters = $this->buildTierFilters($filters, $tier); + $results = $this->qdrantService->search($query, $tierFilters, $limit, $project); + + return $this->rankAndLabel($results, $tier); + } + + /** + * Calculate tiered search score: relevance * confidence_weight * freshness_decay. + * + * @param array $entry + */ + public function calculateScore(array $entry): float + { + $relevance = (float) ($entry['score'] ?? 0.0); + $confidenceWeight = $this->calculateConfidenceWeight($entry); + $freshnessDecay = $this->calculateFreshnessDecay($entry); + + return $relevance * $confidenceWeight * $freshnessDecay; + } + + /** + * Calculate confidence weight as a 0-1 multiplier. + * + * @param array $entry + */ + public function calculateConfidenceWeight(array $entry): float + { + $effectiveConfidence = $this->metadataService->calculateEffectiveConfidence($entry); + + return $effectiveConfidence / 100.0; + } + + /** + * Calculate freshness decay using exponential decay with half-life. + * + * @param array $entry + */ + public function calculateFreshnessDecay(array $entry): float + { + $updatedAt = $entry['updated_at'] ?? $entry['created_at'] ?? null; + + if (! is_string($updatedAt) || $updatedAt === '') { + return 0.5; + } + + $daysSince = Carbon::parse($updatedAt)->diffInDays(now()); + + return pow(0.5, $daysSince / self::FRESHNESS_HALF_LIFE_DAYS); + } + + /** + * Check if results contain confident matches above threshold. + * + * @param Collection> $results + */ + private function hasConfidentMatches(Collection $results): bool + { + if ($results->isEmpty()) { + return false; + } + + return $results->contains(fn (array $entry): bool => ($entry['tiered_score'] ?? 0.0) >= self::CONFIDENCE_THRESHOLD); + } + + /** + * Search all tiers and merge/deduplicate results. + * + * @param array $filters + * @return Collection> + */ + private function searchAllTiers( + string $query, + array $filters, + int $limit, + string $project, + ): Collection { + $allResults = collect(); + + foreach (SearchTier::searchOrder() as $tier) { + $results = $this->searchTier($query, $filters, $limit, $tier, $project); + $allResults = $allResults->merge($results); + } + + // Deduplicate by ID, keeping the highest scored version + return $allResults + ->groupBy('id') + ->map(function (Collection $group): array { + $first = $group->sortByDesc('tiered_score')->first(); + + return is_array($first) ? $first : []; + }) + ->filter(fn (array $entry): bool => count($entry) > 0) + ->values() + ->sortByDesc('tiered_score') + ->take($limit) + ->values(); + } + + /** + * Build Qdrant filters for a specific tier. + * + * @param array $filters + * @return array + */ + private function buildTierFilters(array $filters, SearchTier $tier): array + { + return match ($tier) { + SearchTier::Working => array_merge($filters, ['status' => 'draft']), + SearchTier::Recent => $filters, + SearchTier::Structured => array_merge($filters, ['status' => 'validated']), + SearchTier::Archive => array_merge($filters, ['status' => 'deprecated']), + }; + } + + /** + * Apply ranking formula and add tier labels to results. + * + * @param Collection> $results + * @return Collection> + */ + private function rankAndLabel(Collection $results, SearchTier $tier): Collection + { + return $results + ->filter(fn (array $entry): bool => $this->entryMatchesTierTimeConstraint($entry, $tier)) + ->map(function (array $entry) use ($tier): array { + $entry['tier'] = $tier->value; + $entry['tier_label'] = $tier->label(); + $entry['tiered_score'] = $this->calculateScore($entry); + + return $entry; + }) + ->sortByDesc('tiered_score') + ->values(); + } + + /** + * Check if an entry matches the time constraint for a tier. + * + * @param array $entry + */ + private function entryMatchesTierTimeConstraint(array $entry, SearchTier $tier): bool + { + if ($tier !== SearchTier::Recent) { + return true; + } + + $updatedAt = $entry['updated_at'] ?? $entry['created_at'] ?? null; + + if (! is_string($updatedAt) || $updatedAt === '') { + return false; + } + + return Carbon::parse($updatedAt)->diffInDays(now()) <= self::RECENT_DAYS; + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9c5b152..8076a45 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -40,6 +40,11 @@ parameters: count: 1 path: app/Commands/KnowledgeListCommand.php + - + message: "#^Parameter \\#2 \\$filters of method App\\\\Services\\\\TieredSearchService\\:\\:search\\(\\) expects array\\, array\\ given\\.$#" + count: 1 + path: app/Commands/KnowledgeSearchCommand.php + - message: "#^Variable \\$tags in isset\\(\\) always exists and is not nullable\\.$#" count: 1 @@ -224,3 +229,8 @@ parameters: message: "#^Unable to resolve the template type TValue in call to function collect$#" count: 4 path: app/Services/QdrantService.php + + - + message: "#^Parameter \\#2 \\$filters of method App\\\\Services\\\\QdrantService\\:\\:search\\(\\) expects array\\{tag\\?\\: string, category\\?\\: string, module\\?\\: string, priority\\?\\: string, status\\?\\: string, include_superseded\\?\\: bool\\}, array\\ given\\.$#" + count: 1 + path: app/Services/TieredSearchService.php diff --git a/tests/Feature/Commands/KnowledgeSearchCommandTest.php b/tests/Feature/Commands/KnowledgeSearchCommandTest.php index 6447d5f..cd5b470 100644 --- a/tests/Feature/Commands/KnowledgeSearchCommandTest.php +++ b/tests/Feature/Commands/KnowledgeSearchCommandTest.php @@ -2,11 +2,18 @@ declare(strict_types=1); -use App\Services\QdrantService; +use App\Services\EntryMetadataService; +use App\Services\TieredSearchService; beforeEach(function (): void { - $this->mockQdrant = Mockery::mock(QdrantService::class); - $this->app->instance(QdrantService::class, $this->mockQdrant); + $this->mockTieredSearch = Mockery::mock(TieredSearchService::class); + $this->app->instance(TieredSearchService::class, $this->mockTieredSearch); + + $this->mockMetadata = Mockery::mock(EntryMetadataService::class); + $this->mockMetadata->shouldReceive('isStale')->andReturn(false); + $this->mockMetadata->shouldReceive('calculateEffectiveConfidence')->andReturn(80); + $this->mockMetadata->shouldReceive('confidenceLevel')->andReturn('high'); + $this->app->instance(EntryMetadataService::class, $this->mockMetadata); }); afterEach(function (): void { @@ -14,9 +21,9 @@ }); it('searches entries by keyword in title and content', function (): void { - $this->mockQdrant->shouldReceive('search') + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('timezone', [], 20) + ->with('timezone', [], 20, null) ->andReturn(collect([ [ 'id' => 'entry-1', @@ -32,6 +39,9 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.85, ], ])); @@ -42,9 +52,9 @@ }); it('searches entries by tag', function (): void { - $this->mockQdrant->shouldReceive('search') + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('', ['tag' => 'blood.notifications'], 20) + ->with('', ['tag' => 'blood.notifications'], 20, null) ->andReturn(collect([ [ 'id' => 'entry-1', @@ -60,6 +70,9 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.68, ], ])); @@ -69,9 +82,9 @@ }); it('searches entries by category', function (): void { - $this->mockQdrant->shouldReceive('search') + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('', ['category' => 'architecture'], 20) + ->with('', ['category' => 'architecture'], 20, null) ->andReturn(collect([ [ 'id' => 'entry-1', @@ -87,6 +100,9 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', + 'tier' => 'structured', + 'tier_label' => 'Structured Storage', + 'tiered_score' => 0.76, ], ])); @@ -96,9 +112,9 @@ }); it('searches entries by category and module', function (): void { - $this->mockQdrant->shouldReceive('search') + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('', ['category' => 'architecture', 'module' => 'Blood'], 20) + ->with('', ['category' => 'architecture', 'module' => 'Blood'], 20, null) ->andReturn(collect([ [ 'id' => 'entry-1', @@ -114,6 +130,9 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', + 'tier' => 'structured', + 'tier_label' => 'Structured Storage', + 'tiered_score' => 0.82, ], ])); @@ -125,9 +144,9 @@ }); it('searches entries by priority', function (): void { - $this->mockQdrant->shouldReceive('search') + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('', ['priority' => 'critical'], 20) + ->with('', ['priority' => 'critical'], 20, null) ->andReturn(collect([ [ 'id' => 'entry-1', @@ -143,6 +162,9 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', + 'tier' => 'structured', + 'tier_label' => 'Structured Storage', + 'tiered_score' => 0.93, ], ])); @@ -152,9 +174,9 @@ }); it('searches entries by status', function (): void { - $this->mockQdrant->shouldReceive('search') + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('', ['status' => 'validated'], 20) + ->with('', ['status' => 'validated'], 20, null) ->andReturn(collect([ [ 'id' => 'entry-1', @@ -170,6 +192,9 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', + 'tier' => 'structured', + 'tier_label' => 'Structured Storage', + 'tiered_score' => 0.74, ], ])); @@ -179,9 +204,9 @@ }); it('shows message when no results found', function (): void { - $this->mockQdrant->shouldReceive('search') + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('nonexistent', [], 20) + ->with('nonexistent', [], 20, null) ->andReturn(collect([])); $this->artisan('search', ['query' => 'nonexistent']) @@ -190,13 +215,13 @@ }); it('searches with multiple filters', function (): void { - $this->mockQdrant->shouldReceive('search') + $this->mockTieredSearch->shouldReceive('search') ->once() ->with('', [ 'category' => 'testing', 'module' => 'Blood', 'priority' => 'high', - ], 20) + ], 20, null) ->andReturn(collect([ [ 'id' => 'entry-1', @@ -212,6 +237,9 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', + 'tier' => 'structured', + 'tier_label' => 'Structured Storage', + 'tiered_score' => 0.83, ], ])); @@ -224,54 +252,52 @@ }); it('requires at least one search parameter', function (): void { - $this->mockQdrant->shouldNotReceive('search'); + $this->mockTieredSearch->shouldNotReceive('search'); $this->artisan('search') ->assertFailed() ->expectsOutput('Please provide at least one search parameter.'); }); -// TODO: Fix test isolation issue - this test passes in isolation but fails when run with others -// it('displays entry details with score and truncates long content', function () { -// $this->mockQdrant->shouldReceive('search') -// ->once() -// ->with('detailed', [], 20) -// ->andReturn(collect([ -// [ -// 'id' => 'test-123', -// 'title' => 'Detailed Entry', -// 'content' => 'This is a very long content that exceeds 100 characters to test the truncation feature in the search output display', -// 'category' => 'testing', -// 'priority' => 'high', -// 'confidence' => 85, -// 'module' => 'TestModule', -// 'tags' => ['tag1', 'tag2'], -// 'score' => 0.92, -// 'status' => 'validated', -// 'usage_count' => 0, -// 'created_at' => '2025-01-01T00:00:00Z', -// 'updated_at' => '2025-01-01T00:00:00Z', -// ], -// ])); -// -// $this->artisan('search', ['query' => 'detailed']) -// ->assertSuccessful() -// ->expectsOutputToContain('Found 1 entry') -// ->expectsOutputToContain('[test-123]') -// ->expectsOutputToContain('Detailed Entry') -// ->expectsOutputToContain('score: 0.92') -// ->expectsOutputToContain('Category: testing') -// ->expectsOutputToContain('Priority: high') -// ->expectsOutputToContain('Confidence: 85%') -// ->expectsOutputToContain('Module: TestModule') -// ->expectsOutputToContain('Tags: tag1, tag2') -// ->expectsOutputToContain('...'); -// }); +it('handles query with all filter types combined', function (): void { + $this->mockTieredSearch->shouldReceive('search') + ->once() + ->with('laravel', [ + 'tag' => 'testing', + 'category' => 'architecture', + 'module' => 'Core', + 'priority' => 'high', + 'status' => 'validated', + ], 20, null) + ->andReturn(collect([])); + + $this->artisan('search', [ + 'query' => 'laravel', + '--tag' => 'testing', + '--category' => 'architecture', + '--module' => 'Core', + '--priority' => 'high', + '--status' => 'validated', + ])->assertSuccessful() + ->expectsOutput('No entries found.'); +}); + +it('uses semantic search by default', function (): void { + $this->mockTieredSearch->shouldReceive('search') + ->once() + ->with('semantic', [], 20, null) + ->andReturn(collect([])); + + $this->artisan('search', [ + 'query' => 'semantic', + '--semantic' => true, + ])->assertSuccessful(); +}); it('handles entries with missing optional fields', function (): void { - $this->mockQdrant->shouldReceive('search') + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('minimal', [], 20) + ->with('minimal', [], 20, null) ->andReturn(collect([ [ 'id' => 'minimal-entry', @@ -287,6 +313,9 @@ 'usage_count' => 0, 'created_at' => '2025-01-01T00:00:00Z', 'updated_at' => '2025-01-01T00:00:00Z', + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.05, ], ])); @@ -295,160 +324,115 @@ ->expectsOutputToContain('Category: N/A'); }); -// TODO: Implement --observations flag functionality -// These tests are skipped until the feature is implemented -/* -describe('--observations flag', function (): void { - it('searches observations instead of entries', function (): void { - $session = Session::factory()->create(); - - $observation = Observation::factory()->create([ - 'session_id' => $session->id, - 'title' => 'Authentication Bug Fix', - 'type' => ObservationType::Bugfix, - 'narrative' => 'Fixed OAuth bug', - ]); - - $this->mockFts->shouldReceive('searchObservations') +describe('--tier flag', function (): void { + it('passes working tier to tiered search', function (): void { + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('authentication') - ->andReturn(collect([$observation])); - - $this->mockQdrant->shouldNotReceive('search'); + ->with('query', [], 20, \App\Enums\SearchTier::Working) + ->andReturn(collect([])); - $this->artisan('search', [ - 'query' => 'authentication', - '--observations' => true, - ])->assertSuccessful() - ->expectsOutputToContain('Found 1 observation'); + $this->artisan('search', ['query' => 'query', '--tier' => 'working']) + ->assertSuccessful() + ->expectsOutput('No entries found.'); }); - it('shows no observations message when none found', function (): void { - $this->mockFts->shouldReceive('searchObservations') + it('passes recent tier to tiered search', function (): void { + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('nonexistent') + ->with('query', [], 20, \App\Enums\SearchTier::Recent) ->andReturn(collect([])); - $this->artisan('search', [ - 'query' => 'nonexistent', - '--observations' => true, - ])->assertSuccessful() - ->expectsOutput('No observations found.'); + $this->artisan('search', ['query' => 'query', '--tier' => 'recent']) + ->assertSuccessful() + ->expectsOutput('No entries found.'); }); - it('displays observation type, title, concept, and created date', function (): void { - $session = Session::factory()->create(); - - $observation = Observation::factory()->create([ - 'session_id' => $session->id, - 'title' => 'Bug Fix', - 'type' => ObservationType::Bugfix, - 'concept' => 'Authentication', - 'narrative' => 'Fixed auth bug', - ]); - - $this->mockFts->shouldReceive('searchObservations') + it('passes structured tier to tiered search', function (): void { + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('bug') - ->andReturn(collect([$observation])); - - $output = $this->artisan('search', [ - 'query' => 'bug', - '--observations' => true, - ]); - - $output->assertSuccessful() - ->expectsOutputToContain('Bug Fix') - ->expectsOutputToContain('Type: bugfix') - ->expectsOutputToContain('Concept: Authentication'); + ->with('query', [], 20, \App\Enums\SearchTier::Structured) + ->andReturn(collect([])); + + $this->artisan('search', ['query' => 'query', '--tier' => 'structured']) + ->assertSuccessful() + ->expectsOutput('No entries found.'); }); - it('requires query when using observations flag', function (): void { - $this->mockFts->shouldNotReceive('searchObservations'); + it('passes archive tier to tiered search', function (): void { + $this->mockTieredSearch->shouldReceive('search') + ->once() + ->with('query', [], 20, \App\Enums\SearchTier::Archive) + ->andReturn(collect([])); - $this->artisan('search', [ - '--observations' => true, - ])->assertFailed() - ->expectsOutput('Please provide a search query when using --observations.'); + $this->artisan('search', ['query' => 'query', '--tier' => 'archive']) + ->assertSuccessful() + ->expectsOutput('No entries found.'); }); - it('counts observations correctly', function (): void { - $session = Session::factory()->create(); + it('rejects invalid tier value', function (): void { + $this->mockTieredSearch->shouldNotReceive('search'); - $observations = Observation::factory(3)->create([ - 'session_id' => $session->id, - 'title' => 'Test Observation', - 'narrative' => 'Test narrative', - ]); + $this->artisan('search', ['query' => 'query', '--tier' => 'invalid']) + ->assertFailed(); + }); - $this->mockFts->shouldReceive('searchObservations') + it('displays tier label in results', function (): void { + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('test') - ->andReturn($observations); - - $this->artisan('search', [ - 'query' => 'test', - '--observations' => true, - ])->assertSuccessful() - ->expectsOutput('Found 3 observations'); + ->andReturn(collect([ + [ + 'id' => 'uuid-1', + 'title' => 'Test Entry', + 'content' => 'Content', + 'category' => 'testing', + 'priority' => 'medium', + 'confidence' => 80, + 'module' => null, + 'tags' => [], + 'score' => 0.90, + 'status' => 'draft', + 'usage_count' => 0, + 'created_at' => '2026-02-09T00:00:00Z', + 'updated_at' => '2026-02-09T00:00:00Z', + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.72, + ], + ])); + + $this->artisan('search', ['query' => 'test', '--tier' => 'working']) + ->assertSuccessful() + ->expectsOutputToContain('Found 1 entry') + ->expectsOutputToContain('Test Entry'); }); - it('truncates long observation narratives', function (): void { - $session = Session::factory()->create(); - - $longNarrative = str_repeat('This is a very long narrative. ', 10); - - $observation = Observation::factory()->create([ - 'session_id' => $session->id, - 'title' => 'Long Narrative Observation', - 'narrative' => $longNarrative, - ]); - - $this->mockFts->shouldReceive('searchObservations') + it('displays ranked score in results', function (): void { + $this->mockTieredSearch->shouldReceive('search') ->once() - ->with('narrative') - ->andReturn(collect([$observation])); - - $this->artisan('search', [ - 'query' => 'narrative', - '--observations' => true, - ])->assertSuccessful() - ->expectsOutputToContain('...'); + ->andReturn(collect([ + [ + 'id' => 'uuid-1', + 'title' => 'Test Entry', + 'content' => 'Content', + 'category' => 'testing', + 'priority' => 'medium', + 'confidence' => 80, + 'module' => null, + 'tags' => [], + 'score' => 0.90, + 'status' => 'validated', + 'usage_count' => 0, + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-01T00:00:00Z', + 'tier' => 'structured', + 'tier_label' => 'Structured Storage', + 'tiered_score' => 0.65, + ], + ])); + + $this->artisan('search', ['query' => 'test']) + ->assertSuccessful() + ->expectsOutputToContain('Found 1 entry') + ->expectsOutputToContain('Test Entry'); }); }); -*/ - -it('handles query with all filter types combined', function (): void { - $this->mockQdrant->shouldReceive('search') - ->once() - ->with('laravel', [ - 'tag' => 'testing', - 'category' => 'architecture', - 'module' => 'Core', - 'priority' => 'high', - 'status' => 'validated', - ], 20) - ->andReturn(collect([])); - - $this->artisan('search', [ - 'query' => 'laravel', - '--tag' => 'testing', - '--category' => 'architecture', - '--module' => 'Core', - '--priority' => 'high', - '--status' => 'validated', - ])->assertSuccessful() - ->expectsOutput('No entries found.'); -}); - -it('uses semantic search by default', function (): void { - $this->mockQdrant->shouldReceive('search') - ->once() - ->with('semantic', [], 20) - ->andReturn(collect([])); - - $this->artisan('search', [ - 'query' => 'semantic', - '--semantic' => true, - ])->assertSuccessful(); -}); diff --git a/tests/Feature/KnowledgeSearchCommandTest.php b/tests/Feature/KnowledgeSearchCommandTest.php index 78cf66b..a8fe772 100644 --- a/tests/Feature/KnowledgeSearchCommandTest.php +++ b/tests/Feature/KnowledgeSearchCommandTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -use App\Services\QdrantService; +use App\Services\TieredSearchService; describe('KnowledgeSearchCommand', function (): void { beforeEach(function (): void { - $this->qdrantService = mock(QdrantService::class); + $this->tieredSearch = mock(TieredSearchService::class); - app()->instance(QdrantService::class, $this->qdrantService); + app()->instance(TieredSearchService::class, $this->tieredSearch); }); it('requires at least one parameter', function (): void { @@ -18,9 +18,9 @@ }); it('finds entries by keyword', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() - ->with('Laravel', [], 20) + ->with('Laravel', [], 20, null) ->andReturn(collect([ [ 'id' => 'uuid-1', @@ -33,6 +33,9 @@ 'status' => 'draft', 'confidence' => 95, 'score' => 0.95, + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.90, ], ])); @@ -43,9 +46,9 @@ }); it('filters by tag', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() - ->with('', ['tag' => 'php'], 20) + ->with('', ['tag' => 'php'], 20, null) ->andReturn(collect([ [ 'id' => 'uuid-2', @@ -58,6 +61,9 @@ 'status' => 'draft', 'confidence' => 90, 'score' => 0.90, + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.81, ], ])); @@ -68,9 +74,9 @@ }); it('filters by category', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() - ->with('', ['category' => 'tutorial'], 20) + ->with('', ['category' => 'tutorial'], 20, null) ->andReturn(collect([ [ 'id' => 'uuid-1', @@ -83,6 +89,9 @@ 'status' => 'draft', 'confidence' => 95, 'score' => 0.95, + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.90, ], ])); @@ -93,7 +102,7 @@ }); it('shows no results message', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() ->andReturn(collect([])); @@ -103,9 +112,9 @@ }); it('supports semantic flag', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() - ->with('Laravel', [], 20) + ->with('Laravel', [], 20, null) ->andReturn(collect([ [ 'id' => 'uuid-1', @@ -118,6 +127,9 @@ 'status' => 'draft', 'confidence' => 95, 'score' => 0.95, + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.90, ], ])); @@ -131,9 +143,9 @@ }); it('combines query and filters', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() - ->with('Laravel', ['category' => 'tutorial'], 20) + ->with('Laravel', ['category' => 'tutorial'], 20, null) ->andReturn(collect([ [ 'id' => 'uuid-1', @@ -146,6 +158,9 @@ 'status' => 'draft', 'confidence' => 95, 'score' => 0.95, + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.90, ], ])); @@ -159,7 +174,7 @@ }); it('shows entry details', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() ->andReturn(collect([ [ @@ -173,6 +188,9 @@ 'status' => 'validated', 'confidence' => 95, 'score' => 0.95, + 'tier' => 'structured', + 'tier_label' => 'Structured Storage', + 'tiered_score' => 0.90, ], ])); @@ -184,7 +202,7 @@ }); it('truncates long content', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() ->andReturn(collect([ [ @@ -198,6 +216,9 @@ 'status' => 'draft', 'confidence' => 100, 'score' => 0.85, + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.85, ], ])); @@ -207,7 +228,7 @@ }); it('displays multiple search results', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() ->andReturn(collect([ [ @@ -221,6 +242,9 @@ 'status' => 'draft', 'confidence' => 95, 'score' => 0.95, + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.90, ], [ 'id' => 'uuid-2', @@ -233,6 +257,9 @@ 'status' => 'draft', 'confidence' => 90, 'score' => 0.85, + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.76, ], ])); @@ -244,7 +271,7 @@ }); it('supports multiple filters simultaneously', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() ->with('', [ 'category' => 'testing', @@ -252,7 +279,7 @@ 'priority' => 'high', 'status' => 'validated', 'tag' => 'laravel', - ], 20) + ], 20, null) ->andReturn(collect([])); $this->artisan('search', [ @@ -267,7 +294,7 @@ }); it('handles empty tags array gracefully', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() ->andReturn(collect([ [ @@ -281,6 +308,9 @@ 'status' => 'draft', 'confidence' => 50, 'score' => 0.75, + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.37, ], ])); @@ -290,7 +320,7 @@ }); it('displays score in results', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() ->andReturn(collect([ [ @@ -305,6 +335,9 @@ 'confidence' => 80, 'score' => 0.92, 'superseded_by' => null, + 'tier' => 'working', + 'tier_label' => 'Working Context', + 'tiered_score' => 0.73, ], ])); @@ -314,9 +347,9 @@ }); it('passes include_superseded filter when flag is set', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() - ->with('test', Mockery::on(fn ($filters): bool => isset($filters['include_superseded']) && $filters['include_superseded'] === true), 20) + ->with('test', Mockery::on(fn ($filters): bool => isset($filters['include_superseded']) && $filters['include_superseded'] === true), 20, null) ->andReturn(collect([ [ 'id' => 'uuid-1', @@ -344,9 +377,9 @@ }); it('does not pass include_superseded by default', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() - ->with('test', [], 20) + ->with('test', [], 20, null) ->andReturn(collect([])); $this->artisan('search', ['query' => 'test']) @@ -354,7 +387,7 @@ }); it('shows superseded indicator on superseded entries', function (): void { - $this->qdrantService->shouldReceive('search') + $this->tieredSearch->shouldReceive('search') ->once() ->andReturn(collect([ [ diff --git a/tests/Unit/Enums/SearchTierTest.php b/tests/Unit/Enums/SearchTierTest.php new file mode 100644 index 0000000..d31f8e5 --- /dev/null +++ b/tests/Unit/Enums/SearchTierTest.php @@ -0,0 +1,47 @@ +toHaveCount(4); + }); + + it('has correct string values', function (): void { + expect(SearchTier::Working->value)->toBe('working'); + expect(SearchTier::Recent->value)->toBe('recent'); + expect(SearchTier::Structured->value)->toBe('structured'); + expect(SearchTier::Archive->value)->toBe('archive'); + }); + + it('returns human-readable labels', function (): void { + expect(SearchTier::Working->label())->toBe('Working Context'); + expect(SearchTier::Recent->label())->toBe('Recent (14 days)'); + expect(SearchTier::Structured->label())->toBe('Structured Storage'); + expect(SearchTier::Archive->label())->toBe('Archive'); + }); + + it('returns search order from narrow to wide', function (): void { + $order = SearchTier::searchOrder(); + + expect($order)->toHaveCount(4); + expect($order[0])->toBe(SearchTier::Working); + expect($order[1])->toBe(SearchTier::Recent); + expect($order[2])->toBe(SearchTier::Structured); + expect($order[3])->toBe(SearchTier::Archive); + }); + + it('can be created from string value', function (): void { + expect(SearchTier::from('working'))->toBe(SearchTier::Working); + expect(SearchTier::from('recent'))->toBe(SearchTier::Recent); + expect(SearchTier::from('structured'))->toBe(SearchTier::Structured); + expect(SearchTier::from('archive'))->toBe(SearchTier::Archive); + }); + + it('returns null for invalid value with tryFrom', function (): void { + expect(SearchTier::tryFrom('invalid'))->toBeNull(); + expect(SearchTier::tryFrom(''))->toBeNull(); + }); +}); diff --git a/tests/Unit/Services/TieredSearchServiceTest.php b/tests/Unit/Services/TieredSearchServiceTest.php new file mode 100644 index 0000000..c2be475 --- /dev/null +++ b/tests/Unit/Services/TieredSearchServiceTest.php @@ -0,0 +1,574 @@ +qdrantService = Mockery::mock(QdrantService::class); + $this->metadataService = new EntryMetadataService; + $this->service = new TieredSearchService($this->qdrantService, $this->metadataService); +}); + +afterEach(function (): void { + Carbon::setTestNow(); + Mockery::close(); +}); + +describe('search', function (): void { + it('returns early when confident matches found at working tier', function (): void { + Carbon::setTestNow('2026-02-10'); + + $this->qdrantService->shouldReceive('search') + ->once() + ->with('test query', ['status' => 'draft'], 20, 'default') + ->andReturn(collect([ + [ + 'id' => 'uuid-1', + 'score' => 0.95, + 'title' => 'Working Entry', + 'content' => 'Recent draft content', + 'tags' => ['test'], + 'category' => 'testing', + 'module' => null, + 'priority' => 'high', + 'status' => 'draft', + 'confidence' => 90, + 'usage_count' => 5, + 'created_at' => '2026-02-09T00:00:00+00:00', + 'updated_at' => '2026-02-09T00:00:00+00:00', + 'last_verified' => '2026-02-09T00:00:00+00:00', + 'evidence' => null, + ], + ])); + + $results = $this->service->search('test query'); + + expect($results)->toHaveCount(1); + expect($results->first()['tier'])->toBe('working'); + expect($results->first()['tier_label'])->toBe('Working Context'); + }); + + it('searches multiple tiers when no confident matches found', function (): void { + Carbon::setTestNow('2026-02-10'); + + // Working tier - no results + $this->qdrantService->shouldReceive('search') + ->with('test query', ['status' => 'draft'], 20, 'default') + ->andReturn(collect([])); + + // Recent tier - no results (nothing within 14 days) + $this->qdrantService->shouldReceive('search') + ->with('test query', [], 20, 'default') + ->andReturn(collect([])); + + // Structured tier - no results + $this->qdrantService->shouldReceive('search') + ->with('test query', ['status' => 'validated'], 20, 'default') + ->andReturn(collect([])); + + // Archive tier - no results + $this->qdrantService->shouldReceive('search') + ->with('test query', ['status' => 'deprecated'], 20, 'default') + ->andReturn(collect([])); + + // All tiers search for fallback (4 more calls) + $this->qdrantService->shouldReceive('search') + ->with('test query', ['status' => 'draft'], 20, 'default') + ->andReturn(collect([])); + + $this->qdrantService->shouldReceive('search') + ->with('test query', [], 20, 'default') + ->andReturn(collect([])); + + $this->qdrantService->shouldReceive('search') + ->with('test query', ['status' => 'validated'], 20, 'default') + ->andReturn(collect([])); + + $this->qdrantService->shouldReceive('search') + ->with('test query', ['status' => 'deprecated'], 20, 'default') + ->andReturn(collect([])); + + $results = $this->service->search('test query'); + + expect($results)->toBeEmpty(); + }); + + it('forces search on a specific tier when forceTier is set', function (): void { + Carbon::setTestNow('2026-02-10'); + + $this->qdrantService->shouldReceive('search') + ->once() + ->with('test query', ['status' => 'validated'], 20, 'default') + ->andReturn(collect([ + [ + 'id' => 'uuid-2', + 'score' => 0.85, + 'title' => 'Structured Entry', + 'content' => 'Validated content', + 'tags' => [], + 'category' => 'architecture', + 'module' => null, + 'priority' => 'medium', + 'status' => 'validated', + 'confidence' => 80, + 'usage_count' => 10, + 'created_at' => '2026-01-01T00:00:00+00:00', + 'updated_at' => '2026-02-01T00:00:00+00:00', + 'last_verified' => '2026-02-01T00:00:00+00:00', + 'evidence' => null, + ], + ])); + + $results = $this->service->search('test query', [], 20, SearchTier::Structured); + + expect($results)->toHaveCount(1); + expect($results->first()['tier'])->toBe('structured'); + expect($results->first()['tier_label'])->toBe('Structured Storage'); + }); + + it('forces search on archive tier', function (): void { + Carbon::setTestNow('2026-02-10'); + + $this->qdrantService->shouldReceive('search') + ->once() + ->with('old query', ['status' => 'deprecated'], 10, 'default') + ->andReturn(collect([ + [ + 'id' => 'uuid-3', + 'score' => 0.70, + 'title' => 'Archived Entry', + 'content' => 'Old deprecated content', + 'tags' => ['legacy'], + 'category' => 'deployment', + 'module' => null, + 'priority' => 'low', + 'status' => 'deprecated', + 'confidence' => 30, + 'usage_count' => 1, + 'created_at' => '2025-06-01T00:00:00+00:00', + 'updated_at' => '2025-06-01T00:00:00+00:00', + 'last_verified' => null, + 'evidence' => null, + ], + ])); + + $results = $this->service->search('old query', [], 10, SearchTier::Archive); + + expect($results)->toHaveCount(1); + expect($results->first()['tier'])->toBe('archive'); + expect($results->first()['tier_label'])->toBe('Archive'); + }); + + it('passes filters through to tier search', function (): void { + Carbon::setTestNow('2026-02-10'); + + $this->qdrantService->shouldReceive('search') + ->once() + ->with('query', ['tag' => 'php', 'status' => 'draft'], 20, 'default') + ->andReturn(collect([ + [ + 'id' => 'uuid-4', + 'score' => 0.90, + 'title' => 'PHP Working Entry', + 'content' => 'PHP draft content', + 'tags' => ['php'], + 'category' => 'testing', + 'module' => null, + 'priority' => 'high', + 'status' => 'draft', + 'confidence' => 95, + 'usage_count' => 2, + 'created_at' => '2026-02-09T00:00:00+00:00', + 'updated_at' => '2026-02-09T00:00:00+00:00', + 'last_verified' => '2026-02-09T00:00:00+00:00', + 'evidence' => null, + ], + ])); + + $results = $this->service->search('query', ['tag' => 'php'], 20, SearchTier::Working); + + expect($results)->toHaveCount(1); + expect($results->first()['tier'])->toBe('working'); + }); + + it('uses custom project namespace', function (): void { + Carbon::setTestNow('2026-02-10'); + + $this->qdrantService->shouldReceive('search') + ->once() + ->with('query', ['status' => 'validated'], 20, 'my-project') + ->andReturn(collect([])); + + $results = $this->service->search('query', [], 20, SearchTier::Structured, 'my-project'); + + expect($results)->toBeEmpty(); + }); +}); + +describe('searchTier', function (): void { + it('adds tier and tier_label to results', function (): void { + Carbon::setTestNow('2026-02-10'); + + $this->qdrantService->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'uuid-1', + 'score' => 0.90, + 'title' => 'Test Entry', + 'content' => 'Content', + 'tags' => [], + 'category' => null, + 'module' => null, + 'priority' => 'medium', + 'status' => 'draft', + 'confidence' => 80, + 'usage_count' => 0, + 'created_at' => '2026-02-09T00:00:00+00:00', + 'updated_at' => '2026-02-09T00:00:00+00:00', + 'last_verified' => '2026-02-09T00:00:00+00:00', + 'evidence' => null, + ], + ])); + + $results = $this->service->searchTier('test', [], 20, SearchTier::Working); + + expect($results->first())->toHaveKeys(['tier', 'tier_label', 'tiered_score']); + expect($results->first()['tier'])->toBe('working'); + expect($results->first()['tier_label'])->toBe('Working Context'); + }); + + it('filters recent tier results by 14-day window', function (): void { + Carbon::setTestNow('2026-02-10'); + + $this->qdrantService->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'uuid-recent', + 'score' => 0.90, + 'title' => 'Recent Entry', + 'content' => 'Recent content', + 'tags' => [], + 'category' => null, + 'module' => null, + 'priority' => 'medium', + 'status' => 'validated', + 'confidence' => 80, + 'usage_count' => 0, + 'created_at' => '2026-02-05T00:00:00+00:00', + 'updated_at' => '2026-02-05T00:00:00+00:00', + 'last_verified' => '2026-02-05T00:00:00+00:00', + 'evidence' => null, + ], + [ + 'id' => 'uuid-old', + 'score' => 0.85, + 'title' => 'Old Entry', + 'content' => 'Old content', + 'tags' => [], + 'category' => null, + 'module' => null, + 'priority' => 'medium', + 'status' => 'validated', + 'confidence' => 80, + 'usage_count' => 0, + 'created_at' => '2025-12-01T00:00:00+00:00', + 'updated_at' => '2025-12-01T00:00:00+00:00', + 'last_verified' => '2025-12-01T00:00:00+00:00', + 'evidence' => null, + ], + ])); + + $results = $this->service->searchTier('test', [], 20, SearchTier::Recent); + + expect($results)->toHaveCount(1); + expect($results->first()['id'])->toBe('uuid-recent'); + }); + + it('does not filter non-recent tier by time', function (): void { + Carbon::setTestNow('2026-02-10'); + + $this->qdrantService->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'uuid-old-structured', + 'score' => 0.80, + 'title' => 'Old Structured Entry', + 'content' => 'Content from last year', + 'tags' => [], + 'category' => null, + 'module' => null, + 'priority' => 'medium', + 'status' => 'validated', + 'confidence' => 70, + 'usage_count' => 0, + 'created_at' => '2025-06-01T00:00:00+00:00', + 'updated_at' => '2025-06-01T00:00:00+00:00', + 'last_verified' => '2026-01-01T00:00:00+00:00', + 'evidence' => null, + ], + ])); + + $results = $this->service->searchTier('test', [], 20, SearchTier::Structured); + + expect($results)->toHaveCount(1); + }); + + it('sorts results by tiered score descending', function (): void { + Carbon::setTestNow('2026-02-10'); + + $this->qdrantService->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'uuid-low', + 'score' => 0.60, + 'title' => 'Low Score Entry', + 'content' => 'Content', + 'tags' => [], + 'category' => null, + 'module' => null, + 'priority' => 'low', + 'status' => 'draft', + 'confidence' => 30, + 'usage_count' => 0, + 'created_at' => '2026-01-01T00:00:00+00:00', + 'updated_at' => '2026-01-01T00:00:00+00:00', + 'last_verified' => '2026-01-01T00:00:00+00:00', + 'evidence' => null, + ], + [ + 'id' => 'uuid-high', + 'score' => 0.95, + 'title' => 'High Score Entry', + 'content' => 'Content', + 'tags' => [], + 'category' => null, + 'module' => null, + 'priority' => 'high', + 'status' => 'draft', + 'confidence' => 95, + 'usage_count' => 0, + 'created_at' => '2026-02-09T00:00:00+00:00', + 'updated_at' => '2026-02-09T00:00:00+00:00', + 'last_verified' => '2026-02-09T00:00:00+00:00', + 'evidence' => null, + ], + ])); + + $results = $this->service->searchTier('test', [], 20, SearchTier::Working); + + expect($results->first()['id'])->toBe('uuid-high'); + expect($results->last()['id'])->toBe('uuid-low'); + }); + + it('excludes recent tier entries with no date', function (): void { + Carbon::setTestNow('2026-02-10'); + + $this->qdrantService->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'uuid-nodate', + 'score' => 0.90, + 'title' => 'No Date Entry', + 'content' => 'Content', + 'tags' => [], + 'category' => null, + 'module' => null, + 'priority' => 'medium', + 'status' => 'draft', + 'confidence' => 80, + 'usage_count' => 0, + 'created_at' => '', + 'updated_at' => '', + 'last_verified' => null, + 'evidence' => null, + ], + ])); + + $results = $this->service->searchTier('test', [], 20, SearchTier::Recent); + + expect($results)->toBeEmpty(); + }); +}); + +describe('calculateScore', function (): void { + it('computes score as relevance * confidence_weight * freshness_decay', function (): void { + Carbon::setTestNow('2026-02-10'); + + $entry = [ + 'score' => 0.90, + 'confidence' => 80, + 'created_at' => '2026-02-10T00:00:00+00:00', + 'updated_at' => '2026-02-10T00:00:00+00:00', + 'last_verified' => '2026-02-10T00:00:00+00:00', + ]; + + $score = $this->service->calculateScore($entry); + + // relevance=0.90, confidence_weight=0.80, freshness_decay=1.0 (0 days) + // 0.90 * 0.80 * 1.0 = 0.72 + expect($score)->toBeGreaterThan(0.71); + expect($score)->toBeLessThan(0.73); + }); + + it('degrades score for older entries', function (): void { + Carbon::setTestNow('2026-02-10'); + + $fresh = [ + 'score' => 0.90, + 'confidence' => 80, + 'created_at' => '2026-02-10T00:00:00+00:00', + 'updated_at' => '2026-02-10T00:00:00+00:00', + 'last_verified' => '2026-02-10T00:00:00+00:00', + ]; + + $stale = [ + 'score' => 0.90, + 'confidence' => 80, + 'created_at' => '2025-12-01T00:00:00+00:00', + 'updated_at' => '2025-12-01T00:00:00+00:00', + 'last_verified' => '2025-12-01T00:00:00+00:00', + ]; + + $freshScore = $this->service->calculateScore($fresh); + $staleScore = $this->service->calculateScore($stale); + + expect($freshScore)->toBeGreaterThan($staleScore); + }); + + it('handles entry with zero score', function (): void { + Carbon::setTestNow('2026-02-10'); + + $entry = [ + 'score' => 0.0, + 'confidence' => 90, + 'created_at' => '2026-02-10T00:00:00+00:00', + 'updated_at' => '2026-02-10T00:00:00+00:00', + 'last_verified' => '2026-02-10T00:00:00+00:00', + ]; + + expect($this->service->calculateScore($entry))->toBe(0.0); + }); + + it('handles entry with missing fields gracefully', function (): void { + Carbon::setTestNow('2026-02-10'); + + $entry = []; + + $score = $this->service->calculateScore($entry); + + expect($score)->toBe(0.0); + }); +}); + +describe('calculateConfidenceWeight', function (): void { + it('returns normalized confidence as 0-1 value', function (): void { + $entry = [ + 'confidence' => 80, + 'last_verified' => now()->toIso8601String(), + ]; + + expect($this->service->calculateConfidenceWeight($entry))->toBe(0.8); + }); + + it('applies confidence degradation for stale entries', function (): void { + Carbon::setTestNow('2026-02-10'); + + $entry = [ + 'confidence' => 80, + 'last_verified' => '2025-10-01T00:00:00+00:00', + ]; + + $weight = $this->service->calculateConfidenceWeight($entry); + + // Stale: 132 days since verification, 42 days over 90 threshold + // degradation = round(42 * 0.15) = round(6.3) = 6 + // effective = max(10, 80 - 6) = 74 + // weight = 74 / 100 = 0.74 + expect($weight)->toBe(0.74); + }); + + it('returns minimum weight for zero confidence', function (): void { + $entry = [ + 'confidence' => 0, + 'last_verified' => now()->subDays(200)->toIso8601String(), + ]; + + $weight = $this->service->calculateConfidenceWeight($entry); + + // min confidence is 10, so 10/100 = 0.1 + expect($weight)->toBe(0.1); + }); +}); + +describe('calculateFreshnessDecay', function (): void { + it('returns 1.0 for entry updated today', function (): void { + Carbon::setTestNow('2026-02-10'); + + $entry = [ + 'updated_at' => '2026-02-10T00:00:00+00:00', + ]; + + expect($this->service->calculateFreshnessDecay($entry))->toBe(1.0); + }); + + it('returns 0.5 for entry updated 30 days ago (half-life)', function (): void { + Carbon::setTestNow('2026-02-10'); + + $entry = [ + 'updated_at' => '2026-01-11T00:00:00+00:00', + ]; + + $decay = $this->service->calculateFreshnessDecay($entry); + + expect($decay)->toBeGreaterThan(0.49); + expect($decay)->toBeLessThan(0.51); + }); + + it('returns 0.25 for entry updated 60 days ago', function (): void { + Carbon::setTestNow('2026-02-10'); + + $entry = [ + 'updated_at' => '2025-12-12T00:00:00+00:00', + ]; + + $decay = $this->service->calculateFreshnessDecay($entry); + + expect($decay)->toBeGreaterThan(0.24); + expect($decay)->toBeLessThan(0.26); + }); + + it('falls back to created_at when updated_at is missing', function (): void { + Carbon::setTestNow('2026-02-10'); + + $entry = [ + 'created_at' => '2026-02-10T00:00:00+00:00', + ]; + + expect($this->service->calculateFreshnessDecay($entry))->toBe(1.0); + }); + + it('returns 0.5 when no dates available', function (): void { + $entry = []; + + expect($this->service->calculateFreshnessDecay($entry))->toBe(0.5); + }); + + it('returns 0.5 when dates are empty strings', function (): void { + $entry = [ + 'updated_at' => '', + 'created_at' => '', + ]; + + expect($this->service->calculateFreshnessDecay($entry))->toBe(0.5); + }); +});