From f6bec7f1b11e5042f9e5b2b7b787797bdf2e9c59 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 10 Feb 2026 22:37:12 +0000 Subject: [PATCH 1/2] feat: add background Ollama async auto-tagging (#80) Implement asynchronous entry enhancement via Ollama LLM. Entries are queued for background processing after creation, keeping `know add` fast (<100ms). A worker command processes the queue, generating tags, category, concepts, and summary for each entry. - Add OllamaService for LLM communication and entry enhancement - Add EnhancementQueueService with file-based queue (consistent with OdinSyncService pattern) for tracking entries pending enhancement - Add `enhance:worker` command with --once and --status flags - Add --skip-enhance flag to `know add` for fast writes - Show enhancement status (Yes/Pending/No) in `know show` - Display AI-generated concepts and summary in entry details - Gracefully skip enhancement when Ollama is unavailable - Register new services in AppServiceProvider Closes #80 --- app/Commands/EnhanceWorkerCommand.php | 198 +++++++++++++ app/Commands/KnowledgeAddCommand.php | 13 +- app/Commands/KnowledgeShowCommand.php | 36 ++- app/Providers/AppServiceProvider.php | 10 + app/Services/EnhancementQueueService.php | 239 +++++++++++++++ app/Services/OllamaService.php | 213 +++++++++++++ tests/Feature/EnhanceWorkerCommandTest.php | 279 ++++++++++++++++++ tests/Feature/KnowledgeAddCommandTest.php | 68 +++++ tests/Feature/KnowledgeShowCommandTest.php | 138 +++++++++ .../Services/EnhancementQueueServiceTest.php | 184 ++++++++++++ tests/Unit/Services/OllamaServiceTest.php | 254 ++++++++++++++++ 11 files changed, 1627 insertions(+), 5 deletions(-) create mode 100644 app/Commands/EnhanceWorkerCommand.php create mode 100644 app/Services/EnhancementQueueService.php create mode 100644 app/Services/OllamaService.php create mode 100644 tests/Feature/EnhanceWorkerCommandTest.php create mode 100644 tests/Feature/KnowledgeShowCommandTest.php create mode 100644 tests/Unit/Services/EnhancementQueueServiceTest.php create mode 100644 tests/Unit/Services/OllamaServiceTest.php diff --git a/app/Commands/EnhanceWorkerCommand.php b/app/Commands/EnhanceWorkerCommand.php new file mode 100644 index 0000000..7d52f8e --- /dev/null +++ b/app/Commands/EnhanceWorkerCommand.php @@ -0,0 +1,198 @@ +option('status')) { + return $this->showStatus($queue); + } + + if (! $ollama->isAvailable()) { + warning('Ollama is not available. Skipping enhancement processing.'); + + return self::SUCCESS; + } + + $processOnce = (bool) $this->option('once'); + + if ($processOnce) { + return $this->processOne($queue, $ollama, $qdrant); + } + + return $this->processAll($queue, $ollama, $qdrant); + } + + private function processOne( + EnhancementQueueService $queue, + OllamaService $ollama, + QdrantService $qdrant, + ): int { + $item = $queue->dequeue(); + + if ($item === null) { + info('Enhancement queue is empty.'); + + return self::SUCCESS; + } + + return $this->processItem($item, $queue, $ollama, $qdrant); + } + + private function processAll( + EnhancementQueueService $queue, + OllamaService $ollama, + QdrantService $qdrant, + ): int { + $pending = $queue->pendingCount(); + + if ($pending === 0) { + info('Enhancement queue is empty.'); + + return self::SUCCESS; + } + + info("Processing {$pending} entries in enhancement queue..."); + + $processed = 0; + $failed = 0; + + while (($item = $queue->dequeue()) !== null) { + $result = $this->processItem($item, $queue, $ollama, $qdrant); + + if ($result === self::SUCCESS) { + $processed++; + } else { + $failed++; + } + } + + info("Enhancement complete: {$processed} processed, {$failed} failed."); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * @param array{entry_id: string|int, title: string, content: string, category?: string|null, tags?: array, project: string, queued_at: string} $item + */ + private function processItem( + array $item, + EnhancementQueueService $queue, + OllamaService $ollama, + QdrantService $qdrant, + ): int { + $entryId = $item['entry_id']; + $project = $item['project']; + + $this->line("Enhancing entry: {$item['title']}"); + + $enhancement = $ollama->enhance([ + 'title' => $item['title'], + 'content' => $item['content'], + 'category' => $item['category'] ?? null, + 'tags' => $item['tags'] ?? [], + ]); + + if ($enhancement['tags'] === [] && $enhancement['summary'] === '') { + $queue->recordFailure("Empty enhancement response for entry {$entryId}"); + error("Failed to enhance entry {$entryId}: empty response"); + + return self::FAILURE; + } + + $fields = $this->buildUpdateFields($enhancement, $item); + + $success = $qdrant->updateFields($entryId, $fields, $project); + + if (! $success) { + $queue->recordFailure("Failed to update Qdrant for entry {$entryId}"); + error("Failed to store enhancement for entry {$entryId}"); + + return self::FAILURE; + } + + $queue->recordSuccess(); + $this->line("Enhanced entry: {$item['title']}"); + + return self::SUCCESS; + } + + /** + * Build the fields to update from enhancement results. + * + * @param array{tags: array, category: string|null, concepts: array, summary: string} $enhancement + * @param array{entry_id: string|int, title: string, content: string, category?: string|null, tags?: array, project: string, queued_at: string} $item + * @return array + */ + private function buildUpdateFields(array $enhancement, array $item): array + { + $fields = [ + 'enhanced' => true, + 'enhanced_at' => now()->toIso8601String(), + ]; + + // Merge AI tags with existing tags (deduplicated) + $existingTags = $item['tags'] ?? []; + $allTags = array_values(array_unique(array_merge($existingTags, $enhancement['tags']))); + $fields['tags'] = $allTags; + + // Only set category if not already set + if (($item['category'] ?? null) === null && $enhancement['category'] !== null) { + $fields['category'] = $enhancement['category']; + } + + if ($enhancement['concepts'] !== []) { + $fields['concepts'] = $enhancement['concepts']; + } + + if ($enhancement['summary'] !== '') { + $fields['summary'] = $enhancement['summary']; + } + + return $fields; + } + + private function showStatus(EnhancementQueueService $queue): int + { + $status = $queue->getStatus(); + + info('Enhancement Queue Status'); + + table( + ['Field', 'Value'], + [ + ['Status', $status['status']], + ['Pending', (string) $status['pending']], + ['Processed', (string) $status['processed']], + ['Failed', (string) $status['failed']], + ['Last Processed', $status['last_processed'] ?? 'Never'], + ['Last Error', $status['last_error'] ?? 'None'], + ] + ); + + return self::SUCCESS; + } +} diff --git a/app/Commands/KnowledgeAddCommand.php b/app/Commands/KnowledgeAddCommand.php index 86a8056..472ccf1 100644 --- a/app/Commands/KnowledgeAddCommand.php +++ b/app/Commands/KnowledgeAddCommand.php @@ -5,6 +5,7 @@ namespace App\Commands; use App\Exceptions\Qdrant\DuplicateEntryException; +use App\Services\EnhancementQueueService; use App\Services\GitContextService; use App\Services\QdrantService; use App\Services\WriteGateService; @@ -36,7 +37,8 @@ class KnowledgeAddCommand extends Command {--branch= : Git branch name} {--commit= : Git commit hash} {--no-git : Skip automatic git context detection} - {--force : Skip write gate and duplicate detection}'; + {--force : Skip write gate and duplicate detection} + {--skip-enhance : Skip queueing for Ollama enhancement}'; protected $description = 'Add a new knowledge entry'; @@ -46,7 +48,7 @@ class KnowledgeAddCommand extends Command private const VALID_STATUSES = ['draft', 'validated', 'deprecated']; - public function handle(GitContextService $gitService, QdrantService $qdrant, WriteGateService $writeGate): int + public function handle(GitContextService $gitService, QdrantService $qdrant, WriteGateService $writeGate, EnhancementQueueService $enhancementQueue): int { /** @var string $title */ $title = (string) $this->argument('title'); @@ -82,6 +84,8 @@ public function handle(GitContextService $gitService, QdrantService $qdrant, Wri $noGit = (bool) $this->option('no-git'); /** @var bool $force */ $force = (bool) $this->option('force'); + /** @var bool $skipEnhance */ + $skipEnhance = (bool) $this->option('skip-enhance'); // Validate required fields if ($content === null || $content === '') { @@ -181,6 +185,11 @@ public function handle(GitContextService $gitService, QdrantService $qdrant, Wri return $this->handleDuplicate($e, $data, $qdrant, (int) $confidence); } + // Queue for Ollama enhancement unless skipped + if (! $skipEnhance && (bool) config('search.ollama.enabled', true)) { + $enhancementQueue->queue($data); + } + info('Knowledge entry created!'); $this->displayEntryTable($id, $title, $category, $priority, (int) $confidence, $data['tags'] ?? null); diff --git a/app/Commands/KnowledgeShowCommand.php b/app/Commands/KnowledgeShowCommand.php index 2210b69..c771819 100644 --- a/app/Commands/KnowledgeShowCommand.php +++ b/app/Commands/KnowledgeShowCommand.php @@ -4,6 +4,7 @@ namespace App\Commands; +use App\Services\EnhancementQueueService; use App\Services\EntryMetadataService; use App\Services\QdrantService; use LaravelZero\Framework\Commands\Command; @@ -19,7 +20,7 @@ class KnowledgeShowCommand extends Command protected $description = 'Display full details of a knowledge entry'; - public function handle(QdrantService $qdrant, EntryMetadataService $metadata): int + public function handle(QdrantService $qdrant, EntryMetadataService $metadata, EnhancementQueueService $enhancementQueue): int { $id = $this->argument('id'); @@ -46,7 +47,7 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i $qdrant->incrementUsage($id); - $this->renderEntry($entry, $metadata); + $this->renderEntry($entry, $metadata, $enhancementQueue); // Show supersession history $history = $qdrant->getSupersessionHistory($id); @@ -58,7 +59,7 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i /** * @param array $entry */ - private function renderEntry(array $entry, EntryMetadataService $metadata): void + private function renderEntry(array $entry, EntryMetadataService $metadata, EnhancementQueueService $enhancementQueue): void { $this->newLine(); @@ -111,6 +112,17 @@ private function renderEntry(array $entry, EntryMetadataService $metadata): void $rows[] = ['Superseded Reason', $entry['superseded_reason'] ?? 'N/A']; } + // Enhancement status + $rows[] = ['Enhanced', $this->enhancementStatus($entry, $enhancementQueue)]; + + if (isset($entry['concepts']) && $entry['concepts'] !== []) { + $rows[] = ['Concepts', implode(', ', $entry['concepts'])]; + } + + if (isset($entry['summary']) && $entry['summary'] !== '') { + $rows[] = ['AI Summary', $entry['summary']]; + } + table(['Field', 'Value'], $rows); $this->newLine(); @@ -147,6 +159,24 @@ private function renderSupersessionHistory(array $entry, array $history): void } } + /** + * @param array $entry + */ + private function enhancementStatus(array $entry, EnhancementQueueService $enhancementQueue): string + { + if (isset($entry['enhanced']) && $entry['enhanced'] === true) { + $enhancedAt = $entry['enhanced_at'] ?? 'Unknown'; + + return "Yes ({$enhancedAt})"; + } + + if ($enhancementQueue->isQueued($entry['id'])) { + return 'Pending'; + } + + return 'No'; + } + private function colorize(string $text, string $color): string { return "{$text}"; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index cfefc42..74c980d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,10 +6,12 @@ use App\Contracts\HealthCheckInterface; use App\Services\DailyLogService; use App\Services\DeletionTracker; +use App\Services\EnhancementQueueService; use App\Services\HealthCheckService; use App\Services\KnowledgeCacheService; use App\Services\KnowledgePathService; use App\Services\OdinSyncService; +use App\Services\OllamaService; use App\Services\QdrantService; use App\Services\RuntimeEnvironment; use App\Services\StubEmbeddingService; @@ -165,5 +167,13 @@ public function register(): void // Health check service for service status commands $this->app->singleton(HealthCheckInterface::class, fn (): HealthCheckService => new HealthCheckService); + + // Ollama service + $this->app->singleton(OllamaService::class, fn (): \App\Services\OllamaService => new OllamaService); + + // Enhancement queue service + $this->app->singleton(EnhancementQueueService::class, fn ($app): \App\Services\EnhancementQueueService => new EnhancementQueueService( + $app->make(KnowledgePathService::class) + )); } } diff --git a/app/Services/EnhancementQueueService.php b/app/Services/EnhancementQueueService.php new file mode 100644 index 0000000..0014051 --- /dev/null +++ b/app/Services/EnhancementQueueService.php @@ -0,0 +1,239 @@ +pathService->getKnowledgeDirectory(); + $this->queuePath = $knowledgeDir.'/enhance_queue.json'; + $this->statusPath = $knowledgeDir.'/enhance_status.json'; + } + + /** + * Queue an entry for enhancement. + * + * @param array{id: string|int, title: string, content: string, category?: string|null, tags?: array} $entry + */ + public function queue(array $entry, string $project = 'default'): void + { + $items = $this->loadQueue(); + + $items[] = [ + 'entry_id' => $entry['id'], + 'title' => $entry['title'], + 'content' => $entry['content'], + 'category' => $entry['category'] ?? null, + 'tags' => $entry['tags'] ?? [], + 'project' => $project, + 'queued_at' => now()->toIso8601String(), + ]; + + $this->saveQueue($items); + } + + /** + * Dequeue the next entry for processing. + * + * @return array{entry_id: string|int, title: string, content: string, category?: string|null, tags?: array, project: string, queued_at: string}|null + */ + public function dequeue(): ?array + { + $items = $this->loadQueue(); + + if ($items === []) { + return null; + } + + $item = array_shift($items); + $this->saveQueue($items); + + /** @var array{entry_id: string|int, title: string, content: string, category?: string|null, tags?: array, project: string, queued_at: string} $item */ + return $item; + } + + /** + * Get the number of pending items in the queue. + */ + public function pendingCount(): int + { + return count($this->loadQueue()); + } + + /** + * Check if a specific entry is in the queue. + */ + public function isQueued(string|int $entryId): bool + { + $items = $this->loadQueue(); + + foreach ($items as $item) { + if (($item['entry_id'] ?? null) === $entryId) { + return true; + } + } + + return false; + } + + /** + * Clear the enhancement queue. + */ + public function clear(): void + { + $this->saveQueue([]); + $this->updateStatus('idle', 0); + } + + /** + * Get the current enhancement status. + * + * @return array{status: string, pending: int, processed: int, failed: int, last_processed: string|null, last_error: string|null} + */ + public function getStatus(): array + { + $default = [ + 'status' => 'idle', + 'pending' => 0, + 'processed' => 0, + 'failed' => 0, + 'last_processed' => null, + 'last_error' => null, + ]; + + $pendingCount = $this->pendingCount(); + + if (! file_exists($this->statusPath)) { + $default['pending'] = $pendingCount; + $default['status'] = $pendingCount > 0 ? 'pending' : 'idle'; + + return $default; + } + + $content = file_get_contents($this->statusPath); + if ($content === false) { + $default['pending'] = $pendingCount; + + return $default; + } + + $status = json_decode($content, true); + if (! is_array($status)) { + $default['pending'] = $pendingCount; + + return $default; + } + + return [ + 'status' => $pendingCount > 0 ? 'pending' : ($status['status'] ?? 'idle'), + 'pending' => $pendingCount, + 'processed' => (int) ($status['processed'] ?? 0), + 'failed' => (int) ($status['failed'] ?? 0), + 'last_processed' => $status['last_processed'] ?? null, + 'last_error' => $status['last_error'] ?? null, + ]; + } + + /** + * Record a successful enhancement. + */ + public function recordSuccess(): void + { + $status = $this->getStatus(); + $this->updateStatus( + $this->pendingCount() > 0 ? 'processing' : 'idle', + $this->pendingCount(), + $status['processed'] + 1, + $status['failed'], + now()->toIso8601String() + ); + } + + /** + * Record a failed enhancement. + */ + public function recordFailure(string $error): void + { + $status = $this->getStatus(); + $this->updateStatus( + 'error', + $this->pendingCount(), + $status['processed'], + $status['failed'] + 1, + $status['last_processed'], + $error + ); + } + + /** + * Load the enhancement queue from disk. + * + * @return array> + */ + private function loadQueue(): array + { + if (! file_exists($this->queuePath)) { + return []; + } + + $content = file_get_contents($this->queuePath); + if ($content === false) { + return []; + } + + $data = json_decode($content, true); + + return is_array($data) ? $data : []; + } + + /** + * Save the enhancement queue to disk. + * + * @param array> $queue + */ + private function saveQueue(array $queue): void + { + $dir = dirname($this->queuePath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($this->queuePath, json_encode($queue, JSON_PRETTY_PRINT)); + } + + /** + * Update the enhancement status file. + */ + private function updateStatus( + string $status, + int $pending, + int $processed = 0, + int $failed = 0, + ?string $lastProcessed = null, + ?string $lastError = null + ): void { + $data = [ + 'status' => $status, + 'pending' => $pending, + 'processed' => $processed, + 'failed' => $failed, + 'last_processed' => $lastProcessed, + 'last_error' => $lastError, + ]; + + $dir = dirname($this->statusPath); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($this->statusPath, json_encode($data, JSON_PRETTY_PRINT)); + } +} diff --git a/app/Services/OllamaService.php b/app/Services/OllamaService.php new file mode 100644 index 0000000..ae19ee0 --- /dev/null +++ b/app/Services/OllamaService.php @@ -0,0 +1,213 @@ +isEnabled()) { + return false; + } + + try { + $response = $this->getClient()->get('/api/tags', [ + 'timeout' => 5, + ]); + + return $response->getStatusCode() === 200; + } catch (GuzzleException) { + return false; + } + } + + /** + * Check if Ollama is enabled in configuration. + */ + public function isEnabled(): bool + { + return (bool) config('search.ollama.enabled', true); + } + + /** + * Enhance an entry with AI-generated tags, category, concepts, and summary. + * + * @param array{title: string, content: string, category?: string|null, tags?: array} $entry + * @return array{tags: array, category: string|null, concepts: array, summary: string} + */ + public function enhance(array $entry): array + { + $prompt = $this->buildEnhancementPrompt($entry); + + $response = $this->generate($prompt); + + return $this->parseEnhancementResponse($response, $entry); + } + + /** + * Send a prompt to Ollama and get a response. + */ + public function generate(string $prompt): string + { + $model = config('search.ollama.model', 'llama3.2:3b'); + $timeout = (int) config('search.ollama.timeout', 30); + + try { + $response = $this->getClient()->post('/api/generate', [ + 'json' => [ + 'model' => $model, + 'prompt' => $prompt, + 'stream' => false, + ], + 'timeout' => $timeout, + ]); + + if ($response->getStatusCode() !== 200) { + return ''; + } + + $data = json_decode((string) $response->getBody(), true); + + if (! is_array($data) || ! isset($data['response'])) { + return ''; + } + + return (string) $data['response']; + } catch (GuzzleException) { + return ''; + } + } + + /** + * Build the enhancement prompt for an entry. + * + * @param array{title: string, content: string, category?: string|null, tags?: array} $entry + */ + private function buildEnhancementPrompt(array $entry): string + { + $title = $entry['title']; + $content = $entry['content']; + + return <<} $entry + * @return array{tags: array, category: string|null, concepts: array, summary: string} + */ + private function parseEnhancementResponse(string $response, array $entry): array + { + $default = [ + 'tags' => [], + 'category' => null, + 'concepts' => [], + 'summary' => '', + ]; + + if ($response === '') { + return $default; + } + + // Extract JSON from response (may contain extra text) + $jsonMatch = []; + if (preg_match('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $response, $jsonMatch) !== 1) { + return $default; + } + + $data = json_decode($jsonMatch[0], true); + if (! is_array($data)) { + return $default; + } + + $validCategories = ['debugging', 'architecture', 'testing', 'deployment', 'security']; + + $tags = isset($data['tags']) && is_array($data['tags']) + ? array_values(array_filter($data['tags'], 'is_string')) + : []; + + $category = isset($data['category']) && is_string($data['category']) && in_array($data['category'], $validCategories, true) + ? $data['category'] + : ($entry['category'] ?? null); + + $concepts = isset($data['concepts']) && is_array($data['concepts']) + ? array_values(array_filter($data['concepts'], 'is_string')) + : []; + + $summary = isset($data['summary']) && is_string($data['summary']) + ? $data['summary'] + : ''; + + return [ + 'tags' => $tags, + 'category' => $category, + 'concepts' => $concepts, + 'summary' => $summary, + ]; + } + + /** + * Get or create HTTP client. + */ + protected function getClient(): Client + { + if (! $this->client instanceof Client) { + $this->client = app()->bound(Client::class) + ? app(Client::class) + : $this->createClient(); + } + + return $this->client; + } + + /** + * Create a new HTTP client instance. + * + * @codeCoverageIgnore HTTP client factory - tested via integration + */ + protected function createClient(): Client + { + $host = config('search.ollama.host', 'localhost'); + $port = (int) config('search.ollama.port', 11434); + + return new Client([ + 'base_uri' => "http://{$host}:{$port}", + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + ]); + } +} diff --git a/tests/Feature/EnhanceWorkerCommandTest.php b/tests/Feature/EnhanceWorkerCommandTest.php new file mode 100644 index 0000000..70ba2a6 --- /dev/null +++ b/tests/Feature/EnhanceWorkerCommandTest.php @@ -0,0 +1,279 @@ +ollamaService = mock(OllamaService::class); + $this->qdrantService = mock(QdrantService::class); + $this->queueService = mock(EnhancementQueueService::class); + + app()->instance(OllamaService::class, $this->ollamaService); + app()->instance(QdrantService::class, $this->qdrantService); + app()->instance(EnhancementQueueService::class, $this->queueService); +}); + +describe('enhance:worker command', function (): void { + it('shows status when --status flag is used', function (): void { + $this->queueService->shouldReceive('getStatus') + ->once() + ->andReturn([ + 'status' => 'idle', + 'pending' => 0, + 'processed' => 5, + 'failed' => 1, + 'last_processed' => '2025-06-01T12:00:00+00:00', + 'last_error' => null, + ]); + + $this->artisan('enhance:worker', ['--status' => true]) + ->assertSuccessful(); + }); + + it('exits gracefully when Ollama is unavailable', function (): void { + $this->ollamaService->shouldReceive('isAvailable')->once()->andReturn(false); + + $this->artisan('enhance:worker') + ->assertSuccessful(); + }); + + it('exits successfully when queue is empty', function (): void { + $this->ollamaService->shouldReceive('isAvailable')->once()->andReturn(true); + $this->queueService->shouldReceive('pendingCount')->once()->andReturn(0); + + $this->artisan('enhance:worker') + ->assertSuccessful(); + }); + + it('processes one item with --once flag', function (): void { + $this->ollamaService->shouldReceive('isAvailable')->once()->andReturn(true); + + $this->queueService->shouldReceive('dequeue') + ->once() + ->andReturn([ + 'entry_id' => 'test-id', + 'title' => 'Test Entry', + 'content' => 'Test content', + 'category' => null, + 'tags' => [], + 'project' => 'default', + 'queued_at' => '2025-06-01T12:00:00+00:00', + ]); + + $this->ollamaService->shouldReceive('enhance') + ->once() + ->andReturn([ + 'tags' => ['php', 'testing'], + 'category' => 'testing', + 'concepts' => ['unit testing'], + 'summary' => 'A test entry.', + ]); + + $this->qdrantService->shouldReceive('updateFields') + ->once() + ->with('test-id', Mockery::on(fn ($fields): bool => $fields['enhanced'] === true + && isset($fields['enhanced_at']) + && $fields['tags'] === ['php', 'testing'] + && $fields['category'] === 'testing' + && $fields['concepts'] === ['unit testing'] + && $fields['summary'] === 'A test entry.'), 'default') + ->andReturn(true); + + $this->queueService->shouldReceive('recordSuccess')->once(); + + $this->artisan('enhance:worker', ['--once' => true]) + ->assertSuccessful(); + }); + + it('handles empty queue with --once flag', function (): void { + $this->ollamaService->shouldReceive('isAvailable')->once()->andReturn(true); + $this->queueService->shouldReceive('dequeue')->once()->andReturn(null); + + $this->artisan('enhance:worker', ['--once' => true]) + ->assertSuccessful(); + }); + + it('records failure on empty enhancement response', function (): void { + $this->ollamaService->shouldReceive('isAvailable')->once()->andReturn(true); + + $this->queueService->shouldReceive('dequeue') + ->once() + ->andReturn([ + 'entry_id' => 'test-id', + 'title' => 'Test Entry', + 'content' => 'Test content', + 'category' => null, + 'tags' => [], + 'project' => 'default', + 'queued_at' => '2025-06-01T12:00:00+00:00', + ]); + + $this->ollamaService->shouldReceive('enhance') + ->once() + ->andReturn([ + 'tags' => [], + 'category' => null, + 'concepts' => [], + 'summary' => '', + ]); + + $this->queueService->shouldReceive('recordFailure')->once(); + + $this->artisan('enhance:worker', ['--once' => true]) + ->assertFailed(); + }); + + it('records failure on Qdrant update failure', function (): void { + $this->ollamaService->shouldReceive('isAvailable')->once()->andReturn(true); + + $this->queueService->shouldReceive('dequeue') + ->once() + ->andReturn([ + 'entry_id' => 'test-id', + 'title' => 'Test Entry', + 'content' => 'Test content', + 'category' => null, + 'tags' => [], + 'project' => 'default', + 'queued_at' => '2025-06-01T12:00:00+00:00', + ]); + + $this->ollamaService->shouldReceive('enhance') + ->once() + ->andReturn([ + 'tags' => ['php'], + 'category' => 'testing', + 'concepts' => ['concept'], + 'summary' => 'A summary.', + ]); + + $this->qdrantService->shouldReceive('updateFields') + ->once() + ->andReturn(false); + + $this->queueService->shouldReceive('recordFailure')->once(); + + $this->artisan('enhance:worker', ['--once' => true]) + ->assertFailed(); + }); + + it('processes all items in queue', function (): void { + $this->ollamaService->shouldReceive('isAvailable')->once()->andReturn(true); + $this->queueService->shouldReceive('pendingCount')->once()->andReturn(2); + + $item1 = [ + 'entry_id' => 'id-1', + 'title' => 'Entry 1', + 'content' => 'Content 1', + 'category' => null, + 'tags' => ['existing'], + 'project' => 'default', + 'queued_at' => '2025-06-01T12:00:00+00:00', + ]; + + $item2 = [ + 'entry_id' => 'id-2', + 'title' => 'Entry 2', + 'content' => 'Content 2', + 'category' => 'debugging', + 'tags' => [], + 'project' => 'default', + 'queued_at' => '2025-06-01T12:00:00+00:00', + ]; + + $this->queueService->shouldReceive('dequeue') + ->times(3) + ->andReturn($item1, $item2, null); + + $this->ollamaService->shouldReceive('enhance') + ->twice() + ->andReturn([ + 'tags' => ['ai-tag'], + 'category' => 'architecture', + 'concepts' => ['concept'], + 'summary' => 'A summary.', + ]); + + $this->qdrantService->shouldReceive('updateFields') + ->twice() + ->andReturn(true); + + $this->queueService->shouldReceive('recordSuccess')->twice(); + + $this->artisan('enhance:worker') + ->assertSuccessful(); + }); + + it('preserves existing tags when merging', function (): void { + $this->ollamaService->shouldReceive('isAvailable')->once()->andReturn(true); + + $this->queueService->shouldReceive('dequeue') + ->once() + ->andReturn([ + 'entry_id' => 'test-id', + 'title' => 'Test Entry', + 'content' => 'Test content', + 'category' => null, + 'tags' => ['existing-tag'], + 'project' => 'default', + 'queued_at' => '2025-06-01T12:00:00+00:00', + ]); + + $this->ollamaService->shouldReceive('enhance') + ->once() + ->andReturn([ + 'tags' => ['new-tag', 'existing-tag'], + 'category' => 'testing', + 'concepts' => [], + 'summary' => 'Summary.', + ]); + + $this->qdrantService->shouldReceive('updateFields') + ->once() + ->with('test-id', Mockery::on(fn ($fields): bool => $fields['tags'] === ['existing-tag', 'new-tag']), 'default') + ->andReturn(true); + + $this->queueService->shouldReceive('recordSuccess')->once(); + + $this->artisan('enhance:worker', ['--once' => true]) + ->assertSuccessful(); + }); + + it('does not override existing category', function (): void { + $this->ollamaService->shouldReceive('isAvailable')->once()->andReturn(true); + + $this->queueService->shouldReceive('dequeue') + ->once() + ->andReturn([ + 'entry_id' => 'test-id', + 'title' => 'Test Entry', + 'content' => 'Test content', + 'category' => 'debugging', + 'tags' => [], + 'project' => 'default', + 'queued_at' => '2025-06-01T12:00:00+00:00', + ]); + + $this->ollamaService->shouldReceive('enhance') + ->once() + ->andReturn([ + 'tags' => ['tag'], + 'category' => 'testing', + 'concepts' => [], + 'summary' => 'Summary.', + ]); + + $this->qdrantService->shouldReceive('updateFields') + ->once() + ->with('test-id', Mockery::on(fn ($fields): bool => ! isset($fields['category'])), 'default') + ->andReturn(true); + + $this->queueService->shouldReceive('recordSuccess')->once(); + + $this->artisan('enhance:worker', ['--once' => true]) + ->assertSuccessful(); + }); +}); diff --git a/tests/Feature/KnowledgeAddCommandTest.php b/tests/Feature/KnowledgeAddCommandTest.php index e5ef7e7..21eeae9 100644 --- a/tests/Feature/KnowledgeAddCommandTest.php +++ b/tests/Feature/KnowledgeAddCommandTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Exceptions\Qdrant\DuplicateEntryException; +use App\Services\EnhancementQueueService; use App\Services\GitContextService; use App\Services\QdrantService; use App\Services\WriteGateService; @@ -14,10 +15,16 @@ $this->writeGateService->shouldReceive('evaluate') ->andReturn(['passed' => true, 'matched' => ['durable_facts'], 'reason' => '']) ->byDefault(); + $this->enhancementQueue = mock(EnhancementQueueService::class); app()->instance(GitContextService::class, $this->gitService); app()->instance(QdrantService::class, $this->qdrantService); app()->instance(WriteGateService::class, $this->writeGateService); + app()->instance(EnhancementQueueService::class, $this->enhancementQueue); + + $this->enhancementQueue->shouldReceive('queue')->zeroOrMoreTimes(); + + config(['search.ollama.enabled' => true]); }); it('creates a knowledge entry with required fields', function (): void { @@ -178,6 +185,67 @@ ])->assertFailed(); }); +it('queues entry for enhancement by default', function (): void { + $this->gitService->shouldReceive('isGitRepository')->andReturn(false); + + $this->qdrantService->shouldReceive('upsert') + ->once() + ->andReturn(true); + + // Reset the default expectation and set a specific one + $this->enhancementQueue = mock(EnhancementQueueService::class); + app()->instance(EnhancementQueueService::class, $this->enhancementQueue); + + $this->enhancementQueue->shouldReceive('queue') + ->once() + ->with(Mockery::on(fn ($data): bool => $data['title'] === 'Enhanced Entry')); + + $this->artisan('add', [ + 'title' => 'Enhanced Entry', + '--content' => 'Content to enhance', + ])->assertSuccessful(); +}); + +it('skips enhancement queue with --skip-enhance flag', function (): void { + $this->gitService->shouldReceive('isGitRepository')->andReturn(false); + + $this->qdrantService->shouldReceive('upsert') + ->once() + ->andReturn(true); + + // Reset the default expectation and set a specific one + $this->enhancementQueue = mock(EnhancementQueueService::class); + app()->instance(EnhancementQueueService::class, $this->enhancementQueue); + + $this->enhancementQueue->shouldNotReceive('queue'); + + $this->artisan('add', [ + 'title' => 'Fast Entry', + '--content' => 'Fast content', + '--skip-enhance' => true, + ])->assertSuccessful(); +}); + +it('skips enhancement queue when Ollama is disabled', function (): void { + config(['search.ollama.enabled' => false]); + + $this->gitService->shouldReceive('isGitRepository')->andReturn(false); + + $this->qdrantService->shouldReceive('upsert') + ->once() + ->andReturn(true); + + $this->enhancementQueue = mock(EnhancementQueueService::class); + app()->instance(EnhancementQueueService::class, $this->enhancementQueue); + + $this->enhancementQueue->shouldNotReceive('queue'); + + $this->artisan('add', [ + 'title' => 'No Ollama Entry', + '--content' => 'Content without Ollama', + ])->assertSuccessful(); +}); + it('creates entry with all optional fields', function (): void { $this->gitService->shouldReceive('isGitRepository')->andReturn(false); diff --git a/tests/Feature/KnowledgeShowCommandTest.php b/tests/Feature/KnowledgeShowCommandTest.php new file mode 100644 index 0000000..eea0955 --- /dev/null +++ b/tests/Feature/KnowledgeShowCommandTest.php @@ -0,0 +1,138 @@ +qdrantService = mock(QdrantService::class); + $this->metadataService = mock(EntryMetadataService::class); + $this->enhancementQueue = mock(EnhancementQueueService::class); + + app()->instance(QdrantService::class, $this->qdrantService); + app()->instance(EntryMetadataService::class, $this->metadataService); + app()->instance(EnhancementQueueService::class, $this->enhancementQueue); +}); + +describe('show command', function (): void { + it('displays entry details', function (): void { + $entry = [ + 'id' => 'test-id', + 'title' => 'Test Entry', + 'content' => 'Test content', + 'category' => 'testing', + 'priority' => 'medium', + 'status' => 'draft', + 'confidence' => 75, + 'usage_count' => 3, + 'last_verified' => '2025-06-01T12:00:00+00:00', + 'evidence' => null, + 'module' => null, + 'tags' => ['php'], + 'created_at' => '2025-06-01T12:00:00+00:00', + 'updated_at' => '2025-06-01T12:00:00+00:00', + ]; + + $this->qdrantService->shouldReceive('getById') + ->once() + ->with('test-id') + ->andReturn($entry); + + $this->qdrantService->shouldReceive('incrementUsage') + ->once() + ->with('test-id'); + + $this->metadataService->shouldReceive('isStale')->once()->andReturn(false); + $this->metadataService->shouldReceive('calculateEffectiveConfidence')->once()->andReturn(75); + $this->metadataService->shouldReceive('confidenceLevel')->once()->andReturn('high'); + + $this->enhancementQueue->shouldReceive('isQueued') + ->once() + ->with('test-id') + ->andReturn(false); + + $this->artisan('show', ['id' => 'test-id']) + ->assertSuccessful(); + }); + + it('shows enhancement pending status', function (): void { + $entry = [ + 'id' => 'test-id', + 'title' => 'Test Entry', + 'content' => 'Test content', + 'category' => 'testing', + 'priority' => 'medium', + 'status' => 'draft', + 'confidence' => 50, + 'usage_count' => 0, + 'last_verified' => '2025-06-01T12:00:00+00:00', + 'evidence' => null, + 'module' => null, + 'tags' => [], + 'created_at' => '2025-06-01T12:00:00+00:00', + 'updated_at' => '2025-06-01T12:00:00+00:00', + ]; + + $this->qdrantService->shouldReceive('getById')->once()->andReturn($entry); + $this->qdrantService->shouldReceive('incrementUsage')->once(); + + $this->metadataService->shouldReceive('isStale')->once()->andReturn(false); + $this->metadataService->shouldReceive('calculateEffectiveConfidence')->once()->andReturn(50); + $this->metadataService->shouldReceive('confidenceLevel')->once()->andReturn('medium'); + + $this->enhancementQueue->shouldReceive('isQueued') + ->once() + ->with('test-id') + ->andReturn(true); + + $this->artisan('show', ['id' => 'test-id']) + ->assertSuccessful(); + }); + + it('shows enhanced entry with concepts and summary', function (): void { + $entry = [ + 'id' => 'test-id', + 'title' => 'Test Entry', + 'content' => 'Test content', + 'category' => 'testing', + 'priority' => 'medium', + 'status' => 'draft', + 'confidence' => 50, + 'usage_count' => 0, + 'last_verified' => '2025-06-01T12:00:00+00:00', + 'evidence' => null, + 'module' => null, + 'tags' => ['php', 'testing'], + 'enhanced' => true, + 'enhanced_at' => '2025-06-01T13:00:00+00:00', + 'concepts' => ['unit testing', 'code coverage'], + 'summary' => 'A guide to PHP testing.', + 'created_at' => '2025-06-01T12:00:00+00:00', + 'updated_at' => '2025-06-01T13:00:00+00:00', + ]; + + $this->qdrantService->shouldReceive('getById')->once()->andReturn($entry); + $this->qdrantService->shouldReceive('incrementUsage')->once(); + + $this->metadataService->shouldReceive('isStale')->once()->andReturn(false); + $this->metadataService->shouldReceive('calculateEffectiveConfidence')->once()->andReturn(50); + $this->metadataService->shouldReceive('confidenceLevel')->once()->andReturn('medium'); + + $this->enhancementQueue->shouldReceive('isQueued')->never(); + + $this->artisan('show', ['id' => 'test-id']) + ->assertSuccessful(); + }); + + it('returns failure for non-existent entry', function (): void { + $this->qdrantService->shouldReceive('getById') + ->once() + ->with('missing-id') + ->andReturn(null); + + $this->artisan('show', ['id' => 'missing-id']) + ->assertFailed(); + }); +}); diff --git a/tests/Unit/Services/EnhancementQueueServiceTest.php b/tests/Unit/Services/EnhancementQueueServiceTest.php new file mode 100644 index 0000000..b776182 --- /dev/null +++ b/tests/Unit/Services/EnhancementQueueServiceTest.php @@ -0,0 +1,184 @@ +tempDir = sys_get_temp_dir().'/enhance_queue_test_'.uniqid(); + mkdir($this->tempDir, 0755, true); + + $this->pathService = Mockery::mock(KnowledgePathService::class); + $this->pathService->shouldReceive('getKnowledgeDirectory') + ->andReturn($this->tempDir); +}); + +afterEach(function (): void { + removeDirectory($this->tempDir); +}); + +describe('EnhancementQueueService queue operations', function (): void { + it('queues an entry for enhancement', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $service->queue([ + 'id' => 'test-1', + 'title' => 'Test Entry', + 'content' => 'Test content', + ]); + + expect($service->pendingCount())->toBe(1); + }); + + it('queues multiple entries', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $service->queue(['id' => '1', 'title' => 'First', 'content' => 'Content 1']); + $service->queue(['id' => '2', 'title' => 'Second', 'content' => 'Content 2']); + $service->queue(['id' => '3', 'title' => 'Third', 'content' => 'Content 3']); + + expect($service->pendingCount())->toBe(3); + }); + + it('dequeues entries in FIFO order', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $service->queue(['id' => '1', 'title' => 'First', 'content' => 'Content 1']); + $service->queue(['id' => '2', 'title' => 'Second', 'content' => 'Content 2']); + + $item = $service->dequeue(); + + expect($item)->not->toBeNull(); + expect($item['entry_id'])->toBe('1'); + expect($item['title'])->toBe('First'); + expect($service->pendingCount())->toBe(1); + }); + + it('returns null when dequeuing empty queue', function (): void { + $service = new EnhancementQueueService($this->pathService); + + expect($service->dequeue())->toBeNull(); + }); + + it('checks if entry is queued', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $service->queue(['id' => 'test-1', 'title' => 'Test', 'content' => 'Content']); + + expect($service->isQueued('test-1'))->toBeTrue(); + expect($service->isQueued('test-2'))->toBeFalse(); + }); + + it('clears the queue', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $service->queue(['id' => '1', 'title' => 'Test', 'content' => 'Content']); + expect($service->pendingCount())->toBe(1); + + $service->clear(); + expect($service->pendingCount())->toBe(0); + }); + + it('handles empty queue file gracefully', function (): void { + $service = new EnhancementQueueService($this->pathService); + + expect($service->pendingCount())->toBe(0); + }); + + it('handles corrupted queue file gracefully', function (): void { + file_put_contents($this->tempDir.'/enhance_queue.json', 'not-valid-json'); + $service = new EnhancementQueueService($this->pathService); + + expect($service->pendingCount())->toBe(0); + }); + + it('stores project with queued item', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $service->queue(['id' => '1', 'title' => 'Test', 'content' => 'Content'], 'myproject'); + + $item = $service->dequeue(); + + expect($item['project'])->toBe('myproject'); + }); + + it('stores queued_at timestamp', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $service->queue(['id' => '1', 'title' => 'Test', 'content' => 'Content']); + + $item = $service->dequeue(); + + expect($item['queued_at'])->not->toBeNull(); + }); +}); + +describe('EnhancementQueueService status', function (): void { + it('returns default status when no status file exists', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $status = $service->getStatus(); + + expect($status['status'])->toBe('idle'); + expect($status['pending'])->toBe(0); + expect($status['processed'])->toBe(0); + expect($status['failed'])->toBe(0); + expect($status['last_processed'])->toBeNull(); + expect($status['last_error'])->toBeNull(); + }); + + it('returns pending status when queue has items', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $service->queue(['id' => '1', 'title' => 'Test', 'content' => 'Content']); + $status = $service->getStatus(); + + expect($status['status'])->toBe('pending'); + expect($status['pending'])->toBe(1); + }); + + it('handles corrupted status file gracefully', function (): void { + file_put_contents($this->tempDir.'/enhance_status.json', 'not-valid-json'); + $service = new EnhancementQueueService($this->pathService); + + $status = $service->getStatus(); + + expect($status['status'])->toBe('idle'); + expect($status['pending'])->toBe(0); + }); + + it('records successful processing', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $service->recordSuccess(); + $status = $service->getStatus(); + + expect($status['processed'])->toBe(1); + expect($status['last_processed'])->not->toBeNull(); + }); + + it('records failed processing', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $service->recordFailure('Test error'); + $status = $service->getStatus(); + + expect($status['failed'])->toBe(1); + expect($status['last_error'])->toBe('Test error'); + expect($status['status'])->toBe('error'); + }); + + it('increments counters on multiple operations', function (): void { + $service = new EnhancementQueueService($this->pathService); + + $service->recordSuccess(); + $service->recordSuccess(); + $service->recordFailure('Error 1'); + + $status = $service->getStatus(); + + expect($status['processed'])->toBe(2); + expect($status['failed'])->toBe(1); + }); +}); diff --git a/tests/Unit/Services/OllamaServiceTest.php b/tests/Unit/Services/OllamaServiceTest.php new file mode 100644 index 0000000..12fdc24 --- /dev/null +++ b/tests/Unit/Services/OllamaServiceTest.php @@ -0,0 +1,254 @@ + true]); + config(['search.ollama.host' => 'localhost']); + config(['search.ollama.port' => 11434]); + config(['search.ollama.model' => 'llama3.2:3b']); + config(['search.ollama.timeout' => 30]); +}); + +afterEach(function (): void { + app()->forgetInstance(Client::class); +}); + +describe('OllamaService configuration', function (): void { + it('reports enabled when config is true', function (): void { + $service = new OllamaService; + + expect($service->isEnabled())->toBeTrue(); + }); + + it('reports disabled when config is false', function (): void { + config(['search.ollama.enabled' => false]); + $service = new OllamaService; + + expect($service->isEnabled())->toBeFalse(); + }); +}); + +describe('OllamaService availability', function (): void { + it('reports available when Ollama responds', function (): void { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['models' => []])), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + app()->instance(Client::class, $mockClient); + + $service = new OllamaService; + + expect($service->isAvailable())->toBeTrue(); + }); + + it('reports unavailable on connection error', function (): void { + $mockHandler = new MockHandler([ + new Response(500, [], 'Internal Server Error'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + app()->instance(Client::class, $mockClient); + + $service = new OllamaService; + + expect($service->isAvailable())->toBeFalse(); + }); + + it('reports unavailable when disabled', function (): void { + config(['search.ollama.enabled' => false]); + $service = new OllamaService; + + expect($service->isAvailable())->toBeFalse(); + }); +}); + +describe('OllamaService generate', function (): void { + it('generates a response from Ollama', function (): void { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['response' => 'Hello, world!'])), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + app()->instance(Client::class, $mockClient); + + $service = new OllamaService; + $result = $service->generate('test prompt'); + + expect($result)->toBe('Hello, world!'); + }); + + it('returns empty string on HTTP error', function (): void { + $mockHandler = new MockHandler([ + new Response(500, [], 'Error'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + app()->instance(Client::class, $mockClient); + + $service = new OllamaService; + $result = $service->generate('test prompt'); + + expect($result)->toBe(''); + }); + + it('returns empty string on invalid response', function (): void { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['invalid' => 'data'])), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + app()->instance(Client::class, $mockClient); + + $service = new OllamaService; + $result = $service->generate('test prompt'); + + expect($result)->toBe(''); + }); +}); + +describe('OllamaService enhance', function (): void { + it('enhances an entry with valid JSON response', function (): void { + $jsonResponse = json_encode([ + 'tags' => ['php', 'laravel', 'testing'], + 'category' => 'testing', + 'concepts' => ['unit testing', 'code coverage'], + 'summary' => 'A guide to PHP testing with Laravel.', + ]); + + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['response' => $jsonResponse])), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + app()->instance(Client::class, $mockClient); + + $service = new OllamaService; + $result = $service->enhance([ + 'title' => 'PHP Testing Guide', + 'content' => 'A comprehensive guide to testing PHP applications.', + ]); + + expect($result['tags'])->toBe(['php', 'laravel', 'testing']); + expect($result['category'])->toBe('testing'); + expect($result['concepts'])->toBe(['unit testing', 'code coverage']); + expect($result['summary'])->toBe('A guide to PHP testing with Laravel.'); + }); + + it('returns defaults on empty response', function (): void { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['response' => ''])), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + app()->instance(Client::class, $mockClient); + + $service = new OllamaService; + $result = $service->enhance([ + 'title' => 'Test', + 'content' => 'Content', + ]); + + expect($result['tags'])->toBe([]); + expect($result['category'])->toBeNull(); + expect($result['concepts'])->toBe([]); + expect($result['summary'])->toBe(''); + }); + + it('returns defaults on invalid JSON response', function (): void { + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['response' => 'not valid json at all'])), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + app()->instance(Client::class, $mockClient); + + $service = new OllamaService; + $result = $service->enhance([ + 'title' => 'Test', + 'content' => 'Content', + ]); + + expect($result['tags'])->toBe([]); + expect($result['category'])->toBeNull(); + expect($result['concepts'])->toBe([]); + expect($result['summary'])->toBe(''); + }); + + it('rejects invalid category from Ollama response', function (): void { + $jsonResponse = json_encode([ + 'tags' => ['php'], + 'category' => 'invalid-category', + 'concepts' => ['concept'], + 'summary' => 'A summary.', + ]); + + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['response' => $jsonResponse])), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + app()->instance(Client::class, $mockClient); + + $service = new OllamaService; + $result = $service->enhance([ + 'title' => 'Test', + 'content' => 'Content', + 'category' => 'debugging', + ]); + + expect($result['category'])->toBe('debugging'); + }); + + it('preserves existing category when Ollama returns invalid one', function (): void { + $jsonResponse = json_encode([ + 'tags' => ['php'], + 'category' => 'not-valid', + 'concepts' => [], + 'summary' => '', + ]); + + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['response' => $jsonResponse])), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + app()->instance(Client::class, $mockClient); + + $service = new OllamaService; + $result = $service->enhance([ + 'title' => 'Test', + 'content' => 'Content', + ]); + + expect($result['category'])->toBeNull(); + }); + + it('handles JSON embedded in text', function (): void { + $jsonResponse = 'Here is the analysis: {"tags": ["docker"], "category": "deployment", "concepts": ["containers"], "summary": "Docker guide."} Hope that helps!'; + + $mockHandler = new MockHandler([ + new Response(200, [], json_encode(['response' => $jsonResponse])), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockClient = new Client(['handler' => $handlerStack]); + app()->instance(Client::class, $mockClient); + + $service = new OllamaService; + $result = $service->enhance([ + 'title' => 'Docker Guide', + 'content' => 'How to use Docker.', + ]); + + expect($result['tags'])->toBe(['docker']); + expect($result['category'])->toBe('deployment'); + expect($result['summary'])->toBe('Docker guide.'); + }); +}); From 865817d98e13a8a561fa9bf7175c03492deb85a1 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Wed, 11 Feb 2026 08:50:46 -0700 Subject: [PATCH 2/2] Fix test failures from rebase: add getSupersessionHistory mock + regenerate PHPStan baseline --- phpstan-baseline.neon | 24 +++++++++++----------- tests/Feature/KnowledgeShowCommandTest.php | 4 ++++ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e3d26ef..9c5b152 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -11,27 +11,27 @@ parameters: path: app/Commands/InsightsCommand.php - - message: "#^Parameter \\#1 \\$project of method App\\\\Services\\\\QdrantService\\:\\:ensureCollection\\(\\) expects string, array\\|bool\\|string\\|null given\\.$#" + message: "#^Parameter \\#1 \\$project of method App\\\\Services\\\\QdrantService\\:\\:ensureCollection\\(\\) expects string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#" count: 1 path: app/Commands/InstallCommand.php - - message: "#^Part \\$project \\(array\\|bool\\|string\\|null\\) of encapsed string cannot be cast to string\\.$#" + message: "#^Part \\$project \\(array\\|bool\\|float\\|int\\|string\\|null\\) of encapsed string cannot be cast to string\\.$#" count: 1 path: app/Commands/InstallCommand.php - - message: "#^Cannot cast array\\|bool\\|string\\|null to string\\.$#" + message: "#^Cannot cast array\\|bool\\|float\\|int\\|string\\|null to string\\.$#" count: 1 path: app/Commands/KnowledgeAddCommand.php - - message: "#^Parameter \\#1 \\$entry of method App\\\\Services\\\\QdrantService\\:\\:upsert\\(\\) expects array\\{id\\: int\\|string, title\\: string, content\\: string, tags\\?\\: array\\, category\\?\\: string, module\\?\\: string, priority\\?\\: string, status\\?\\: string, \\.\\.\\.\\}, array\\{title\\: string, content\\: non\\-empty\\-string, category\\: 'architecture'\\|'debugging'\\|'deployment'\\|'security'\\|'testing'\\|null, module\\: string\\|null, priority\\: 'critical'\\|'high'\\|'low'\\|'medium', confidence\\: int, source\\: string\\|null, ticket\\: string\\|null, \\.\\.\\.\\} given\\.$#" + message: "#^Parameter \\#1 \\$entry of method App\\\\Services\\\\QdrantService\\:\\:upsert\\(\\) expects array\\{id\\: int\\|string, title\\: string, content\\: string, tags\\?\\: array\\, category\\?\\: string, module\\?\\: string, priority\\?\\: string, status\\?\\: string, \\.\\.\\.\\}, array\\ given\\.$#" count: 1 path: app/Commands/KnowledgeAddCommand.php - - message: "#^Parameter \\#1 \\$entry of method App\\\\Services\\\\QdrantService\\:\\:upsert\\(\\) expects array\\{id\\: int\\|string, title\\: string, content\\: string, tags\\?\\: array\\, category\\?\\: string, module\\?\\: string, priority\\?\\: string, status\\?\\: string, \\.\\.\\.\\}, array\\ given\\.$#" + message: "#^Parameter \\#1 \\$entry of method App\\\\Services\\\\QdrantService\\:\\:upsert\\(\\) expects array\\{id\\: int\\|string, title\\: string, content\\: string, tags\\?\\: array\\, category\\?\\: string, module\\?\\: string, priority\\?\\: string, status\\?\\: string, \\.\\.\\.\\}, array\\{title\\: string, content\\: non\\-empty\\-string, category\\: 'architecture'\\|'debugging'\\|'deployment'\\|'security'\\|'testing'\\|null, module\\: string\\|null, priority\\: 'critical'\\|'high'\\|'low'\\|'medium', confidence\\: int, source\\: string\\|null, ticket\\: string\\|null, \\.\\.\\.\\} given\\.$#" count: 1 path: app/Commands/KnowledgeAddCommand.php @@ -66,17 +66,17 @@ parameters: path: app/Commands/KnowledgeStatsCommand.php - - message: "#^Parameter \\#1 \\$id of method App\\\\Services\\\\QdrantService\\:\\:getById\\(\\) expects int\\|string, array\\|bool\\|string\\|null given\\.$#" + message: "#^Parameter \\#1 \\$id of method App\\\\Services\\\\QdrantService\\:\\:getById\\(\\) expects int\\|string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#" count: 1 path: app/Commands/KnowledgeValidateCommand.php - - message: "#^Parameter \\#1 \\$id of method App\\\\Services\\\\QdrantService\\:\\:updateFields\\(\\) expects int\\|string, array\\|bool\\|string\\|null given\\.$#" + message: "#^Parameter \\#1 \\$id of method App\\\\Services\\\\QdrantService\\:\\:updateFields\\(\\) expects int\\|string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#" count: 1 path: app/Commands/KnowledgeValidateCommand.php - - message: "#^Part \\$id \\(array\\|bool\\|string\\|null\\) of encapsed string cannot be cast to string\\.$#" + message: "#^Part \\$id \\(array\\|bool\\|float\\|int\\|string\\|null\\) of encapsed string cannot be cast to string\\.$#" count: 2 path: app/Commands/KnowledgeValidateCommand.php @@ -171,22 +171,22 @@ parameters: path: app/Services/QdrantService.php - - message: "#^Method App\\\\Services\\\\QdrantService\\:\\:hybridSearch\\(\\) should return Illuminate\\\\Support\\\\Collection\\, category\\: string\\|null, module\\: string\\|null, priority\\: string\\|null, \\.\\.\\.\\}\\> but returns Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\), array\\\\>\\.$#" + message: "#^Method App\\\\Services\\\\QdrantService\\:\\:findSimilar\\(\\) should return Illuminate\\\\Support\\\\Collection\\ but returns Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\), array\\{id\\: mixed, score\\: mixed, title\\: mixed, content\\: mixed\\}\\>\\.$#" count: 1 path: app/Services/QdrantService.php - - message: "#^Method App\\\\Services\\\\QdrantService\\:\\:scroll\\(\\) should return Illuminate\\\\Support\\\\Collection\\, category\\: string\\|null, module\\: string\\|null, priority\\: string\\|null, status\\: string\\|null, \\.\\.\\.\\}\\> but returns Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\), array\\\\>\\.$#" + message: "#^Method App\\\\Services\\\\QdrantService\\:\\:getById\\(\\) should return array\\{id\\: int\\|string, title\\: string, content\\: string, tags\\: array\\, category\\: string\\|null, module\\: string\\|null, priority\\: string\\|null, status\\: string\\|null, \\.\\.\\.\\}\\|null but returns array\\\\.$#" count: 1 path: app/Services/QdrantService.php - - message: "#^Method App\\\\Services\\\\QdrantService\\:\\:findSimilar\\(\\) should return Illuminate\\\\Support\\\\Collection\\ but returns Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\), array\\{id\\: mixed, score\\: mixed, title\\: mixed, content\\: mixed\\}\\>\\.$#" + message: "#^Method App\\\\Services\\\\QdrantService\\:\\:hybridSearch\\(\\) should return Illuminate\\\\Support\\\\Collection\\, category\\: string\\|null, module\\: string\\|null, priority\\: string\\|null, \\.\\.\\.\\}\\> but returns Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\), array\\\\>\\.$#" count: 1 path: app/Services/QdrantService.php - - message: "#^Method App\\\\Services\\\\QdrantService\\:\\:getById\\(\\) should return array\\{id\\: int\\|string, title\\: string, content\\: string, tags\\: array\\, category\\: string\\|null, module\\: string\\|null, priority\\: string\\|null, status\\: string\\|null, \\.\\.\\.\\}\\|null but returns array\\\\.$#" + message: "#^Method App\\\\Services\\\\QdrantService\\:\\:scroll\\(\\) should return Illuminate\\\\Support\\\\Collection\\, category\\: string\\|null, module\\: string\\|null, priority\\: string\\|null, status\\: string\\|null, \\.\\.\\.\\}\\> but returns Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\), array\\\\>\\.$#" count: 1 path: app/Services/QdrantService.php diff --git a/tests/Feature/KnowledgeShowCommandTest.php b/tests/Feature/KnowledgeShowCommandTest.php index eea0955..e003252 100644 --- a/tests/Feature/KnowledgeShowCommandTest.php +++ b/tests/Feature/KnowledgeShowCommandTest.php @@ -11,6 +11,10 @@ $this->metadataService = mock(EntryMetadataService::class); $this->enhancementQueue = mock(EnhancementQueueService::class); + $this->qdrantService->shouldReceive('getSupersessionHistory') + ->andReturn(['supersedes' => [], 'superseded_by' => null]) + ->byDefault(); + app()->instance(QdrantService::class, $this->qdrantService); app()->instance(EntryMetadataService::class, $this->metadataService); app()->instance(EnhancementQueueService::class, $this->enhancementQueue);