From 82b3e96609ffdce54e92c377f25a5232bfe8aa29 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Thu, 22 Jan 2026 21:44:41 -0700 Subject: [PATCH] feat: add QA command and knowledge capture with Ollama integration - Add `know qa` command for running pint, tests, and PHPStan with AI fix suggestions - Add `know capture` command for automatic insight extraction from conversation context - Add OllamaService for LLM-powered analysis - Fix type safety issues in QdrantService search/scroll methods - Clean up phpstan baseline after resolving errors - Improve MarkdownExporter with proper type handling Co-Authored-By: Claude Opus 4.5 --- app/Commands/InstallCommand.php | 3 +- app/Commands/KnowledgeArchiveCommand.php | 34 +- app/Commands/KnowledgeCaptureCommand.php | 410 ++++++++++++++++++ app/Commands/KnowledgeConfigCommand.php | 2 +- app/Commands/KnowledgeExportAllCommand.php | 6 +- app/Commands/KnowledgeListCommand.php | 5 +- app/Commands/KnowledgeSearchCommand.php | 57 ++- app/Commands/KnowledgeShowCommand.php | 55 ++- app/Commands/KnowledgeStatsCommand.php | 20 +- app/Commands/QaCommand.php | 283 +++++++++++++ app/Commands/SyncCommand.php | 2 +- app/Providers/AppServiceProvider.php | 25 +- app/Services/MarkdownExporter.php | 56 ++- app/Services/OllamaService.php | 97 +++++ app/Services/QdrantService.php | 133 +++--- config/search.php | 2 +- docker-compose.odin.yml | 7 +- phpstan-baseline.neon | 469 +-------------------- phpstan.neon | 4 +- pint.json | 9 +- tests/Feature/AppServiceProviderTest.php | 5 - 21 files changed, 1082 insertions(+), 602 deletions(-) create mode 100644 app/Commands/KnowledgeCaptureCommand.php create mode 100644 app/Commands/QaCommand.php create mode 100644 app/Services/OllamaService.php diff --git a/app/Commands/InstallCommand.php b/app/Commands/InstallCommand.php index bb42082..779bf30 100644 --- a/app/Commands/InstallCommand.php +++ b/app/Commands/InstallCommand.php @@ -20,7 +20,8 @@ class InstallCommand extends Command public function handle(QdrantService $qdrant): int { - $project = $this->option('project'); + /** @var string $project */ + $project = is_string($this->option('project')) ? $this->option('project') : 'default'; note("Initializing collection: knowledge_{$project}"); diff --git a/app/Commands/KnowledgeArchiveCommand.php b/app/Commands/KnowledgeArchiveCommand.php index a3a62ff..e8b5f9d 100644 --- a/app/Commands/KnowledgeArchiveCommand.php +++ b/app/Commands/KnowledgeArchiveCommand.php @@ -56,25 +56,29 @@ public function handle(QdrantService $qdrant): int */ private function archiveEntry(QdrantService $qdrant, array $entry): int { - if ($entry['status'] === 'deprecated') { - $this->warn("Entry #{$entry['id']} is already archived."); + $entryId = is_scalar($entry['id']) ? (int) $entry['id'] : 0; + $entryTitle = is_scalar($entry['title']) ? (string) $entry['title'] : ''; + $entryStatus = is_scalar($entry['status']) ? (string) $entry['status'] : ''; + + if ($entryStatus === 'deprecated') { + $this->warn("Entry #{$entryId} is already archived."); return self::SUCCESS; } - $oldStatus = $entry['status']; + $oldStatus = $entryStatus; - $qdrant->updateFields((int) $entry['id'], [ + $qdrant->updateFields($entryId, [ 'status' => 'deprecated', 'confidence' => 0, ]); - $this->info("Entry #{$entry['id']} has been archived."); + $this->info("Entry #{$entryId} has been archived."); $this->newLine(); - $this->line("Title: {$entry['title']}"); + $this->line("Title: {$entryTitle}"); $this->line("Status: {$oldStatus} -> deprecated"); $this->newLine(); - $this->comment('Restore with: knowledge:archive '.$entry['id'].' --restore'); + $this->comment('Restore with: knowledge:archive '.$entryId.' --restore'); return self::SUCCESS; } @@ -86,24 +90,28 @@ private function archiveEntry(QdrantService $qdrant, array $entry): int */ private function restoreEntry(QdrantService $qdrant, array $entry): int { - if ($entry['status'] !== 'deprecated') { - $this->warn("Entry #{$entry['id']} is not archived (status: {$entry['status']})."); + $entryId = is_scalar($entry['id']) ? (int) $entry['id'] : 0; + $entryTitle = is_scalar($entry['title']) ? (string) $entry['title'] : ''; + $entryStatus = is_scalar($entry['status']) ? (string) $entry['status'] : ''; + + if ($entryStatus !== 'deprecated') { + $this->warn("Entry #{$entryId} is not archived (status: {$entryStatus})."); return self::SUCCESS; } - $qdrant->updateFields((int) $entry['id'], [ + $qdrant->updateFields($entryId, [ 'status' => 'draft', 'confidence' => 50, ]); - $this->info("Entry #{$entry['id']} has been restored."); + $this->info("Entry #{$entryId} has been restored."); $this->newLine(); - $this->line("Title: {$entry['title']}"); + $this->line("Title: {$entryTitle}"); $this->line('Status: deprecated -> draft'); $this->line('Confidence: 50%'); $this->newLine(); - $this->comment('Validate with: knowledge:validate '.$entry['id']); + $this->comment('Validate with: knowledge:validate '.$entryId); return self::SUCCESS; } diff --git a/app/Commands/KnowledgeCaptureCommand.php b/app/Commands/KnowledgeCaptureCommand.php new file mode 100644 index 0000000..e3751cc --- /dev/null +++ b/app/Commands/KnowledgeCaptureCommand.php @@ -0,0 +1,410 @@ +, category: string, priority: string}> + */ + private array $detectionPatterns = [ + 'insight' => [ + 'patterns' => [ + 'figured out', + 'the issue was', + 'root cause', + 'solution:', + 'turns out', + 'realized that', + 'the fix is', + 'problem was', + 'got it working', + 'the answer is', + ], + 'category' => 'debugging', + 'priority' => 'medium', + ], + 'blocker' => [ + 'patterns' => [ + 'blocked by', + 'waiting on', + "can't proceed", + 'stuck on', + 'need help with', + "don't understand", + 'failing because', + 'dependency issue', + 'permission denied', + ], + 'category' => 'blocker', + 'priority' => 'high', + ], + 'decision' => [ + 'patterns' => [ + 'decided to', + 'going with', + 'chosen approach', + 'will use', + 'opted for', + 'strategy:', + 'trade-off:', + 'because we need', + ], + 'category' => 'architecture', + 'priority' => 'medium', + ], + 'milestone' => [ + 'patterns' => [ + 'milestone:', + 'completed', + 'shipped', + 'deployed', + 'finished', + 'released', + 'merged', + 'feature complete', + 'ready for production', + ], + 'category' => 'milestone', + 'priority' => 'high', + ], + ]; + + /** + * Noise patterns to filter out. + * + * @var array + */ + private array $noisePatterns = [ + 'let me check', + 'running command', + "here's the output", + 'let me read', + 'let me search', + 'i will', + "i'll", + 'maybe', + 'might', + 'could try', + 'not sure', + ]; + + public function handle(QdrantService $qdrant): int + { + $context = $this->getContext(); + + if ($context === '') { + // Silent fail - this is expected in hook context when no meaningful content + return self::SUCCESS; + } + + // Check for noise patterns first + if ($this->isNoise($context)) { + return self::SUCCESS; + } + + // Detect what type of knowledge this might be + $detection = $this->detectPatterns($context); + + if ($detection === null) { + // No meaningful patterns detected + return self::SUCCESS; + } + + // Extract structured insight using Ollama + $extracted = $this->extractInsight($context, $detection); + + if ($extracted === null) { + return self::SUCCESS; + } + + // Check for duplicates + if ($this->isDuplicate($qdrant, $extracted['title'], $extracted['content'])) { + if ($this->option('dry-run') === true) { + $this->warn('Skipped (duplicate): '.$extracted['title']); + } + + return self::SUCCESS; + } + + // Dry run - show what would be captured + if ($this->option('dry-run') === true) { + $this->info('Would capture:'); + $this->line(' Title: '.$extracted['title']); + $this->line(' Category: '.$extracted['category']); + $this->line(' Priority: '.$extracted['priority']); + $this->line(' Tags: '.implode(', ', $extracted['tags'])); + $this->line(' Content: '.$extracted['content']); + + return self::SUCCESS; + } + + // Persist the knowledge entry + $project = $this->option('project'); + $session = $this->option('session'); + $tags = $extracted['tags']; + + if (is_string($session) && $session !== '') { + $tags[] = 'session:'.$session; + } + + /** @var array{id: string, title: string, content: string, tags: array, category: string, priority: string, source: string, confidence: int, module?: string} $entry */ + $entry = [ + 'id' => (string) uuid_create(), + 'title' => $extracted['title'], + 'content' => $extracted['content'], + 'category' => $extracted['category'], + 'priority' => $extracted['priority'], + 'tags' => $tags, + 'source' => 'claude-session', + 'confidence' => 70, + ]; + + if (is_string($project) && $project !== '') { + $entry['module'] = $project; + } + + $qdrant->upsert($entry); + + return self::SUCCESS; + } + + /** + * Get context from option or stdin. + */ + private function getContext(): string + { + $context = $this->option('context'); + if (is_string($context) && $context !== '') { + return $context; + } + + $file = $this->option('file'); + if (is_string($file) && $file !== '' && file_exists($file)) { + $content = file_get_contents($file); + + return is_string($content) ? $content : ''; + } + + // Try reading from stdin (non-blocking) + $stdin = ''; + stream_set_blocking(STDIN, false); + while (($line = fgets(STDIN)) !== false) { + $stdin .= $line; + } + + return trim($stdin); + } + + /** + * Check if content is just noise. + */ + private function isNoise(string $context): bool + { + $lower = strtolower($context); + + foreach ($this->noisePatterns as $pattern) { + if (str_contains($lower, $pattern)) { + return true; + } + } + + // Too short to be meaningful + if (strlen($context) < 50) { + return true; + } + + return false; + } + + /** + * Detect which type of knowledge this represents. + * + * @return array{type: string, category: string, priority: string}|null + */ + private function detectPatterns(string $context): ?array + { + $lower = strtolower($context); + + foreach ($this->detectionPatterns as $type => $config) { + foreach ($config['patterns'] as $pattern) { + if (str_contains($lower, $pattern)) { + return [ + 'type' => $type, + 'category' => $config['category'], + 'priority' => $config['priority'], + ]; + } + } + } + + return null; + } + + /** + * Extract structured insight using Ollama. + * + * @param array{type: string, category: string, priority: string} $detection + * @return array{title: string, content: string, category: string, priority: string, tags: array}|null + */ + private function extractInsight(string $context, array $detection): ?array + { + /** @var array $config */ + $config = config('search.ollama', []); + + $ollama = new OllamaService( + host: is_string($config['host'] ?? null) ? $config['host'] : 'localhost', + port: is_int($config['port'] ?? null) ? $config['port'] : 11434, + model: is_string($config['model'] ?? null) ? $config['model'] : 'mistral:7b', + timeout: is_int($config['timeout'] ?? null) ? $config['timeout'] : 30, + ); + + $prompt = <<generate($prompt); + + if ($response === '') { + // Ollama unavailable - fall back to simple extraction + return $this->simpleExtract($context, $detection); + } + + // Parse JSON response + $json = $this->parseJson($response); + + if ($json === null || isset($json['skip'])) { + return null; + } + + $title = is_string($json['title'] ?? null) ? $json['title'] : ''; + $content = is_string($json['content'] ?? null) ? $json['content'] : ''; + + if ($title === '' || $content === '') { + return null; + } + + /** @var array $tags */ + $tags = is_array($json['tags'] ?? null) ? array_filter($json['tags'], 'is_string') : []; + + return [ + 'title' => $title, + 'content' => $content, + 'category' => $detection['category'], + 'priority' => $detection['priority'], + 'tags' => $tags, + ]; + } + + /** + * Simple extraction fallback when Ollama is unavailable. + * + * @param array{type: string, category: string, priority: string} $detection + * @return array{title: string, content: string, category: string, priority: string, tags: array} + */ + private function simpleExtract(string $context, array $detection): array + { + // Take first sentence as title + $sentences = preg_split('/[.!?]+/', $context, 2); + $title = is_array($sentences) && isset($sentences[0]) ? trim($sentences[0]) : $context; + $title = substr($title, 0, 80); + + // Content is the full context, trimmed + $content = substr(trim($context), 0, 500); + + return [ + 'title' => $title, + 'content' => $content, + 'category' => $detection['category'], + 'priority' => $detection['priority'], + 'tags' => [$detection['type']], + ]; + } + + /** + * Parse JSON from potentially messy LLM output. + * + * @return array|null + */ + private function parseJson(string $response): ?array + { + // Try direct parse first + $json = json_decode($response, true); + if (is_array($json)) { + return $json; + } + + // Try to extract JSON from markdown code block + if (preg_match('/```(?:json)?\s*(\{.*?\})\s*```/s', $response, $matches) === 1) { + $json = json_decode($matches[1], true); + if (is_array($json)) { + return $json; + } + } + + // Try to find JSON object in response + if (preg_match('/\{[^{}]*\}/s', $response, $matches) === 1) { + $json = json_decode($matches[0], true); + if (is_array($json)) { + return $json; + } + } + + return null; + } + + /** + * Check if this insight already exists (semantic deduplication). + */ + private function isDuplicate(QdrantService $qdrant, string $title, string $content): bool + { + // Search for similar entries + $searchText = $title.' '.$content; + $results = $qdrant->search($searchText, [], 3); + + foreach ($results as $result) { + $score = is_float($result['score'] ?? null) ? $result['score'] : 0.0; + // High similarity threshold for deduplication + if ($score > 0.85) { + return true; + } + } + + return false; + } +} diff --git a/app/Commands/KnowledgeConfigCommand.php b/app/Commands/KnowledgeConfigCommand.php index 5c2f2b5..4ccffa1 100644 --- a/app/Commands/KnowledgeConfigCommand.php +++ b/app/Commands/KnowledgeConfigCommand.php @@ -275,7 +275,7 @@ private function formatValue(mixed $value): string return 'null'; } - return (string) $value; + return is_scalar($value) ? (string) $value : 'complex'; // @codeCoverageIgnoreEnd } diff --git a/app/Commands/KnowledgeExportAllCommand.php b/app/Commands/KnowledgeExportAllCommand.php index b6ad026..bd9b0cf 100644 --- a/app/Commands/KnowledgeExportAllCommand.php +++ b/app/Commands/KnowledgeExportAllCommand.php @@ -88,9 +88,11 @@ public function handle(MarkdownExporter $markdownExporter, QdrantService $qdrant private function generateFilename(array $entry, string $format): string { $extension = $format === 'json' ? 'json' : 'md'; - $slug = $this->slugify($entry['title']); + $title = is_scalar($entry['title']) ? (string) $entry['title'] : 'untitled'; + $slug = $this->slugify($title); + $id = is_scalar($entry['id']) ? (string) $entry['id'] : '0'; - return "{$entry['id']}-{$slug}.{$extension}"; + return "{$id}-{$slug}.{$extension}"; } /** diff --git a/app/Commands/KnowledgeListCommand.php b/app/Commands/KnowledgeListCommand.php index f054173..0df7fca 100644 --- a/app/Commands/KnowledgeListCommand.php +++ b/app/Commands/KnowledgeListCommand.php @@ -64,8 +64,9 @@ public function handle(QdrantService $qdrant): int info("Found {$results->count()} ".str('entry')->plural($results->count())); // Build table data - $rows = $results->map(function (array $entry) { - $tags = isset($entry['tags']) && count($entry['tags']) > 0 + /** @var array> $rows */ + $rows = $results->map(function (array $entry): array { + $tags = count($entry['tags']) > 0 ? implode(', ', array_slice($entry['tags'], 0, 3)).(count($entry['tags']) > 3 ? '...' : '') : '-'; diff --git a/app/Commands/KnowledgeSearchCommand.php b/app/Commands/KnowledgeSearchCommand.php index 2aba664..d8992e8 100644 --- a/app/Commands/KnowledgeSearchCommand.php +++ b/app/Commands/KnowledgeSearchCommand.php @@ -4,6 +4,7 @@ namespace App\Commands; +use App\Services\OllamaService; use App\Services\QdrantService; use LaravelZero\Framework\Commands\Command; @@ -20,7 +21,9 @@ class KnowledgeSearchCommand extends Command {--priority= : Filter by priority} {--status= : Filter by status} {--limit=20 : Maximum number of results} - {--semantic : Use semantic search if available}'; + {--preview-length=100 : Character limit for content preview (0 for full)} + {--semantic : Use semantic search if available} + {--summarize= : Use Ollama to summarize results (context: blockers, general)}'; /** * @var string @@ -36,7 +39,9 @@ public function handle(QdrantService $qdrant): int $priority = $this->option('priority'); $status = $this->option('status'); $limit = (int) $this->option('limit'); + $previewLength = (int) $this->option('preview-length'); $useSemantic = $this->option('semantic'); + $summarize = $this->option('summarize'); // Require at least one search parameter for entries if ($query === null && $tag === null && $category === null && $module === null && $priority === null && $status === null) { @@ -64,6 +69,14 @@ public function handle(QdrantService $qdrant): int return self::SUCCESS; } + // If summarize flag is used, pass results through Ollama + if (is_string($summarize) && $summarize !== '') { + /** @var array> $resultsArray */ + $resultsArray = $results->toArray(); + + return $this->outputSummary($resultsArray, $summarize); + } + $this->info("Found {$results->count()} ".str('entry')->plural($results->count())); $this->newLine(); @@ -89,8 +102,8 @@ public function handle(QdrantService $qdrant): int $this->line('Tags: '.implode(', ', $tags)); } - $contentPreview = strlen($content) > 100 - ? substr($content, 0, 100).'...' + $contentPreview = $previewLength > 0 && strlen($content) > $previewLength + ? substr($content, 0, $previewLength).'...' : $content; $this->line($contentPreview); @@ -99,4 +112,42 @@ public function handle(QdrantService $qdrant): int return self::SUCCESS; } + + /** + * Output summarized results using Ollama. + * + * @param array> $results + */ + private function outputSummary(array $results, string $context): int + { + /** @var array $config */ + $config = config('search.ollama', []); + + $ollama = new OllamaService( + host: is_string($config['host'] ?? null) ? $config['host'] : 'localhost', + port: is_int($config['port'] ?? null) ? $config['port'] : 11434, + model: is_string($config['model'] ?? null) ? $config['model'] : 'llama3.2:3b', + timeout: is_int($config['timeout'] ?? null) ? $config['timeout'] : 30, + ); + + $summary = $ollama->summarizeResults($results, $context); + + if ($summary === '') { + $this->warn('Could not generate summary (Ollama unavailable). Showing raw results:'); + $this->newLine(); + + foreach ($results as $result) { + $title = is_scalar($result['title'] ?? null) ? (string) $result['title'] : 'Unknown'; + $content = is_scalar($result['content'] ?? null) ? (string) $result['content'] : ''; + $this->line("- {$title}: {$content}"); + } + + return self::SUCCESS; + } + + $this->info('Summary:'); + $this->line($summary); + + return self::SUCCESS; + } } diff --git a/app/Commands/KnowledgeShowCommand.php b/app/Commands/KnowledgeShowCommand.php index a09cf6e..6c285bf 100644 --- a/app/Commands/KnowledgeShowCommand.php +++ b/app/Commands/KnowledgeShowCommand.php @@ -19,12 +19,17 @@ class KnowledgeShowCommand extends Command public function handle(QdrantService $qdrant): int { - $id = $this->argument('id'); + $rawId = $this->argument('id'); - if (is_numeric($id)) { - $id = (int) $id; + // Type narrowing for PHPStan + if (! is_string($rawId) && ! is_int($rawId)) { + error('Invalid ID provided.'); + + return self::FAILURE; } + $id = is_numeric($rawId) ? (int) $rawId : $rawId; + $entry = spin( fn () => $qdrant->getById($id), 'Fetching entry...' @@ -43,37 +48,55 @@ public function handle(QdrantService $qdrant): int return self::SUCCESS; } + /** + * @param array $entry + */ private function renderEntry(array $entry): void { + // Extract and cast values + $title = is_scalar($entry['title'] ?? null) ? (string) $entry['title'] : ''; + $id = is_scalar($entry['id'] ?? null) ? (string) $entry['id'] : ''; + $content = is_scalar($entry['content'] ?? null) ? (string) $entry['content'] : ''; + $category = is_scalar($entry['category'] ?? null) ? (string) $entry['category'] : 'N/A'; + $priority = is_scalar($entry['priority'] ?? null) ? (string) $entry['priority'] : 'medium'; + $status = is_scalar($entry['status'] ?? null) ? (string) $entry['status'] : 'draft'; + $confidence = is_int($entry['confidence'] ?? null) ? $entry['confidence'] : 0; + $usageCount = is_scalar($entry['usage_count'] ?? null) ? (string) $entry['usage_count'] : '0'; + $module = is_scalar($entry['module'] ?? null) ? (string) $entry['module'] : null; + $createdAt = is_scalar($entry['created_at'] ?? null) ? (string) $entry['created_at'] : ''; + $updatedAt = is_scalar($entry['updated_at'] ?? null) ? (string) $entry['updated_at'] : ''; + /** @var array $tags */ + $tags = is_array($entry['tags'] ?? null) ? $entry['tags'] : []; + $this->newLine(); - $this->line("{$entry['title']}"); - $this->line("ID: {$entry['id']}"); + $this->line("{$title}"); + $this->line("ID: {$id}"); $this->newLine(); - $this->line($entry['content']); + $this->line($content); $this->newLine(); // Metadata table $rows = [ - ['Category', $entry['category'] ?? 'N/A'], - ['Priority', $this->colorize($entry['priority'], $this->priorityColor($entry['priority']))], - ['Status', $this->colorize($entry['status'], $this->statusColor($entry['status']))], - ['Confidence', $this->colorize("{$entry['confidence']}%", $this->confidenceColor($entry['confidence']))], - ['Usage', (string) $entry['usage_count']], + ['Category', $category], + ['Priority', $this->colorize($priority, $this->priorityColor($priority))], + ['Status', $this->colorize($status, $this->statusColor($status))], + ['Confidence', $this->colorize("{$confidence}%", $this->confidenceColor($confidence))], + ['Usage', $usageCount], ]; - if ($entry['module']) { - $rows[] = ['Module', $entry['module']]; + if ($module !== null) { + $rows[] = ['Module', $module]; } - if (! empty($entry['tags'])) { - $rows[] = ['Tags', implode(', ', $entry['tags'])]; + if (count($tags) > 0) { + $rows[] = ['Tags', implode(', ', $tags)]; } table(['Field', 'Value'], $rows); $this->newLine(); - $this->line("Created: {$entry['created_at']} | Updated: {$entry['updated_at']}"); + $this->line("Created: {$createdAt} | Updated: {$updatedAt}"); } private function colorize(string $text, string $color): string diff --git a/app/Commands/KnowledgeStatsCommand.php b/app/Commands/KnowledgeStatsCommand.php index ed2cd28..b5f5e98 100644 --- a/app/Commands/KnowledgeStatsCommand.php +++ b/app/Commands/KnowledgeStatsCommand.php @@ -33,6 +33,9 @@ public function handle(QdrantService $qdrant): int return self::SUCCESS; } + /** + * @param Collection> $entries + */ private function renderDashboard(Collection $entries, int $total): void { info("Knowledge Base: {$total} entries"); @@ -40,15 +43,17 @@ private function renderDashboard(Collection $entries, int $total): void // Overview metrics $totalUsage = $entries->sum('usage_count'); - $avgUsage = round($entries->avg('usage_count') ?? 0); + $avgUsage = $entries->avg('usage_count'); + $totalUsageStr = is_numeric($totalUsage) ? (string) (int) $totalUsage : '0'; + $avgUsageStr = is_numeric($avgUsage) ? (string) (int) round((float) $avgUsage) : '0'; $this->line('Overview'); table( ['Metric', 'Value'], [ ['Total Entries', (string) $total], - ['Total Usage', (string) $totalUsage], - ['Avg Usage', (string) $avgUsage], + ['Total Usage', $totalUsageStr], + ['Avg Usage', $avgUsageStr], ] ); @@ -89,10 +94,15 @@ private function renderDashboard(Collection $entries, int $total): void // Most used $mostUsed = $entries->sortByDesc('usage_count')->first(); - if ($mostUsed && $mostUsed['usage_count'] > 0) { + $usageCount = 0; + if (is_array($mostUsed) && is_int($mostUsed['usage_count'] ?? null)) { + $usageCount = $mostUsed['usage_count']; + } + if ($usageCount > 0 && is_array($mostUsed)) { + $title = is_scalar($mostUsed['title'] ?? null) ? (string) $mostUsed['title'] : 'Unknown'; $this->newLine(); $this->line('Most Used'); - $this->line(" \"{$mostUsed['title']}\" ({$mostUsed['usage_count']} uses)"); + $this->line(" \"{$title}\" ({$usageCount} uses)"); } } } diff --git a/app/Commands/QaCommand.php b/app/Commands/QaCommand.php new file mode 100644 index 0000000..33e66f9 --- /dev/null +++ b/app/Commands/QaCommand.php @@ -0,0 +1,283 @@ +option('timeout'); + + // 1. Try Pint first + if ($this->option('skip-pint') !== true) { + $result = $this->runCheck('pint', $timeout); + if ($result !== null) { + return $this->reportIssue('pint', $result); + } + } + + // 2. Try Tests + if ($this->option('skip-tests') !== true) { + $result = $this->runCheck('test', $timeout); + if ($result !== null) { + return $this->reportIssue('test', $result); + } + } + + // 3. Try PHPStan + if ($this->option('skip-phpstan') !== true) { + $result = $this->runCheck('phpstan', $timeout); + if ($result !== null) { + return $this->reportIssue('phpstan', $result); + } + } + + $this->info('All QA checks passed.'); + + return self::SUCCESS; + } + + private function runCheck(string $type, int $timeout): ?string + { + $label = match ($type) { + 'pint' => 'Code style', + 'test' => 'Tests', + 'phpstan' => 'Static analysis', + default => $type, + }; + + $issue = null; + + $this->task($label, function () use ($type, $timeout, &$issue): bool { + $process = $this->buildProcess($type); + $process->setTimeout($timeout); + + try { + $process->run(); + + if (! $process->isSuccessful()) { + $output = $process->getOutput(); + $issue = $output !== '' ? $output : $process->getErrorOutput(); + + return false; + } + + return true; + } catch (\Throwable $e) { + $issue = $e->getMessage(); + + return false; + } + }); + + return $issue; + } + + private function buildProcess(string $type): Process + { + $projectRoot = base_path(); + + return match ($type) { + 'pint' => new Process([ + $projectRoot.'/vendor/bin/pint', + '--test', + '--config='.$projectRoot.'/pint.json', + $projectRoot.'/app', + $projectRoot.'/config', + $projectRoot.'/tests', + ], $projectRoot), + 'test' => new Process([$projectRoot.'/vendor/bin/pest', '--parallel'], $projectRoot), + 'phpstan' => $this->buildPhpstanProcess(), + default => throw new \InvalidArgumentException("Unknown check type: {$type}"), + }; + } + + private function buildPhpstanProcess(): Process + { + /** @var int|string $level */ + $level = $this->option('level'); + $level = is_numeric($level) ? (int) $level : 9; + + $args = [ + 'vendor/bin/phpstan', + 'analyse', + '--level='.$level, + '--no-progress', + '--error-format=raw', + ]; + + if (file_exists(base_path('phpstan.neon'))) { + $args[] = '--configuration='.base_path('phpstan.neon'); + } + + return new Process($args, base_path()); + } + + private function reportIssue(string $type, string $issue): int + { + $this->newLine(); + + // --all: Show everything + if ($this->option('all') === true) { + $this->line($issue); + + return self::FAILURE; + } + + // --raw: Show raw issue + if ($this->option('raw') === true) { + $this->line($issue); + + return self::FAILURE; + } + + // Extract first meaningful issue + $firstIssue = $this->extractFirstIssue($type, $issue); + + // --no-ai: Show extracted issue without AI + if ($this->option('no-ai') === true) { + $this->line($firstIssue); + + return self::FAILURE; + } + + // Default: Get AI suggestion + $this->task('AI suggestion', function () use ($type, $firstIssue, &$suggestion): bool { + $suggestion = $this->getAiSuggestion($type, $firstIssue); + + return $suggestion !== ''; + }); + + $this->newLine(); + $this->line($suggestion ?? $firstIssue); + + return self::FAILURE; + } + + private function extractFirstIssue(string $type, string $output): string + { + $lines = array_values(array_filter( + explode("\n", $output), + fn ($l): bool => trim($l) !== '' + )); + + return match ($type) { + 'pint' => $this->extractPintIssue($lines), + 'test' => $this->extractTestIssue($lines), + 'phpstan' => $this->extractPhpstanIssue($lines), + default => $lines[0] ?? $output, + }; + } + + /** + * @param array $lines + */ + private function extractPintIssue(array $lines): string + { + foreach ($lines as $line) { + if (str_contains($line, '.php')) { + return trim($line); + } + } + + return implode("\n", array_slice($lines, 0, 5)); + } + + /** + * @param array $lines + */ + private function extractTestIssue(array $lines): string + { + $capture = false; + $issue = []; + + foreach ($lines as $line) { + if (str_contains($line, 'FAILED') || str_contains($line, 'Error')) { + $capture = true; + } + if ($capture) { + $issue[] = $line; + if (count($issue) >= 10) { + break; + } + } + } + + return $issue !== [] ? implode("\n", $issue) : implode("\n", array_slice($lines, 0, 10)); + } + + /** + * @param array $lines + */ + private function extractPhpstanIssue(array $lines): string + { + foreach ($lines as $line) { + $trimmed = trim($line); + if ($trimmed !== '' && ! str_contains($trimmed, 'Ignored error pattern')) { + return $trimmed; + } + } + + return $lines[0] ?? ''; + } + + private function getAiSuggestion(string $type, string $issue): string + { + /** @var array $config */ + $config = config('search.ollama', []); + + $ollama = new OllamaService( + host: is_string($config['host'] ?? null) ? $config['host'] : 'localhost', + port: is_int($config['port'] ?? null) ? $config['port'] : 11434, + model: is_string($config['model'] ?? null) ? $config['model'] : 'llama3.2:3b', + timeout: is_int($config['timeout'] ?? null) ? $config['timeout'] : 30, + ); + + $typeLabel = match ($type) { + 'pint' => 'code style', + 'test' => 'test failure', + 'phpstan' => 'static analysis', + default => $type, + }; + + $prompt = <<generate($prompt); + } +} diff --git a/app/Commands/SyncCommand.php b/app/Commands/SyncCommand.php index 1c96b39..9b0db55 100644 --- a/app/Commands/SyncCommand.php +++ b/app/Commands/SyncCommand.php @@ -197,7 +197,7 @@ private function pullFromCloud(string $token, QdrantService $qdrant): array $created++; } // @codeCoverageIgnoreEnd - // @codeCoverageIgnoreStart + // @codeCoverageIgnoreStart } catch (\Exception $e) { $failed++; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6c12ac9..1a48618 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -49,18 +49,27 @@ public function register(): void return new StubEmbeddingService; } + $serverUrl = config('search.qdrant.embedding_server', 'http://localhost:8001'); + return new \App\Services\EmbeddingService( - config('search.qdrant.embedding_server', 'http://localhost:8001') + is_string($serverUrl) ? $serverUrl : 'http://localhost:8001' ); }); // Qdrant vector database service - $this->app->singleton(QdrantService::class, fn ($app) => new QdrantService( - $app->make(EmbeddingServiceInterface::class), - (int) config('search.embedding_dimension', 1024), - (float) config('search.minimum_similarity', 0.7), - (int) config('search.qdrant.cache_ttl', 604800), - (bool) config('search.qdrant.secure', false) - )); + $this->app->singleton(QdrantService::class, function ($app) { + $dimension = config('search.embedding_dimension', 1024); + $similarity = config('search.minimum_similarity', 0.7); + $cacheTtl = config('search.qdrant.cache_ttl', 604800); + $secure = config('search.qdrant.secure', false); + + return new QdrantService( + $app->make(EmbeddingServiceInterface::class), + is_numeric($dimension) ? (int) $dimension : 1024, + is_numeric($similarity) ? (float) $similarity : 0.7, + is_numeric($cacheTtl) ? (int) $cacheTtl : 604800, + is_bool($secure) ? $secure : false + ); + }); } } diff --git a/app/Services/MarkdownExporter.php b/app/Services/MarkdownExporter.php index 10e5cba..55941c0 100644 --- a/app/Services/MarkdownExporter.php +++ b/app/Services/MarkdownExporter.php @@ -14,9 +14,10 @@ class MarkdownExporter public function exportArray(array $entry): string { $frontMatter = $this->buildFrontMatterFromArray($entry); - $content = $entry['content'] ?? ''; + $content = is_string($entry['content'] ?? null) ? $entry['content'] : ''; + $title = is_string($entry['title'] ?? null) ? $entry['title'] : ''; - return "---\n{$frontMatter}---\n\n# {$entry['title']}\n\n{$content}\n"; + return "---\n{$frontMatter}---\n\n# {$title}\n\n{$content}\n"; } /** @@ -26,36 +27,61 @@ public function exportArray(array $entry): string */ private function buildFrontMatterFromArray(array $entry): string { + $id = $this->getString($entry, 'id'); + $title = $this->getString($entry, 'title'); + $category = $this->getString($entry, 'category'); + $module = $this->getString($entry, 'module'); + $priority = $this->getString($entry, 'priority'); + $confidence = is_numeric($entry['confidence'] ?? null) ? $entry['confidence'] : 0; + $status = $this->getString($entry, 'status'); + $usageCount = is_numeric($entry['usage_count'] ?? null) ? $entry['usage_count'] : 0; + $createdAt = $this->getString($entry, 'created_at'); + $updatedAt = $this->getString($entry, 'updated_at'); + $yaml = []; - $yaml[] = "id: {$entry['id']}"; - $yaml[] = "title: \"{$this->escapeYaml($entry['title'])}\""; + $yaml[] = "id: {$id}"; + $yaml[] = "title: \"{$this->escapeYaml($title)}\""; - if (! empty($entry['category'])) { - $yaml[] = "category: \"{$this->escapeYaml($entry['category'])}\""; + if ($category !== '') { + $yaml[] = "category: \"{$this->escapeYaml($category)}\""; } - if (! empty($entry['module'])) { - $yaml[] = "module: \"{$this->escapeYaml($entry['module'])}\""; + if ($module !== '') { + $yaml[] = "module: \"{$this->escapeYaml($module)}\""; } - $yaml[] = "priority: \"{$entry['priority']}\""; - $yaml[] = "confidence: {$entry['confidence']}"; - $yaml[] = "status: \"{$entry['status']}\""; + $yaml[] = "priority: \"{$priority}\""; + $yaml[] = "confidence: {$confidence}"; + $yaml[] = "status: \"{$status}\""; if (! empty($entry['tags']) && is_array($entry['tags']) && count($entry['tags']) > 0) { $yaml[] = 'tags:'; foreach ($entry['tags'] as $tag) { - $yaml[] = " - \"{$this->escapeYaml($tag)}\""; + if (is_string($tag)) { + $yaml[] = " - \"{$this->escapeYaml($tag)}\""; + } } } - $yaml[] = "usage_count: {$entry['usage_count']}"; - $yaml[] = "created_at: \"{$entry['created_at']}\""; - $yaml[] = "updated_at: \"{$entry['updated_at']}\""; + $yaml[] = "usage_count: {$usageCount}"; + $yaml[] = "created_at: \"{$createdAt}\""; + $yaml[] = "updated_at: \"{$updatedAt}\""; return implode("\n", $yaml)."\n"; } + /** + * Safely get a string value from an array. + * + * @param array $entry + */ + private function getString(array $entry, string $key): string + { + $value = $entry[$key] ?? null; + + return is_string($value) ? $value : ''; + } + /** * Escape special characters for YAML. */ diff --git a/app/Services/OllamaService.php b/app/Services/OllamaService.php new file mode 100644 index 0000000..d5adc24 --- /dev/null +++ b/app/Services/OllamaService.php @@ -0,0 +1,97 @@ +model = $model; + $this->client = new Client([ + 'base_uri' => "http://{$host}:{$port}", + 'timeout' => $timeout, + 'connect_timeout' => 5, + 'http_errors' => false, + ]); + } + + /** + * Summarize search results into actionable context. + * + * @param array> $results + */ + public function summarizeResults(array $results, string $context = 'blockers'): string + { + if ($results === []) { + return ''; + } + + $content = $this->formatResultsForPrompt($results); + + $prompt = match ($context) { + 'blockers' => "Summarize these blockers into 2-3 actionable bullet points. Focus on WHAT is blocked and WHY. Be concise:\n\n{$content}", + default => "Summarize these knowledge entries into 2-3 concise bullet points:\n\n{$content}", + }; + + return $this->generate($prompt); + } + + /** + * Generate text using Ollama. + */ + public function generate(string $prompt): string + { + try { + $response = $this->client->post('/api/generate', [ + 'json' => [ + 'model' => $this->model, + 'prompt' => $prompt, + 'stream' => false, + 'options' => [ + 'temperature' => 0.3, + 'num_predict' => 200, + ], + ], + ]); + + if ($response->getStatusCode() !== 200) { + return ''; + } + + $data = json_decode((string) $response->getBody(), true); + + return is_array($data) && isset($data['response']) + ? trim((string) $data['response']) + : ''; + } catch (\Throwable) { + return ''; + } + } + + /** + * @param array> $results + */ + private function formatResultsForPrompt(array $results): string + { + $lines = []; + foreach ($results as $result) { + $title = is_string($result['title'] ?? null) ? $result['title'] : ''; + $content = is_string($result['content'] ?? null) ? $result['content'] : ''; + $lines[] = "- {$title}: {$content}"; + } + + return implode("\n", $lines); + } +} diff --git a/app/Services/QdrantService.php b/app/Services/QdrantService.php index ad290a3..1a3e66c 100644 --- a/app/Services/QdrantService.php +++ b/app/Services/QdrantService.php @@ -32,10 +32,14 @@ public function __construct( private readonly int $cacheTtl = 604800, // 7 days private readonly bool $secure = false, ) { + $host = config('search.qdrant.host', 'localhost'); + $port = config('search.qdrant.port', 6333); + $apiKey = config('search.qdrant.api_key'); + $this->connector = new QdrantConnector( - host: config('search.qdrant.host', 'localhost'), - port: (int) config("search.qdrant.port", 6333), - apiKey: config('search.qdrant.api_key'), + host: is_string($host) ? $host : 'localhost', + port: is_numeric($port) ? (int) $port : 6333, + apiKey: is_string($apiKey) ? $apiKey : null, secure: $this->secure, ); } @@ -63,9 +67,12 @@ public function ensureCollection(string $project = 'default'): bool if (! $createResponse->successful()) { $error = $createResponse->json(); + $errorMessage = is_array($error) && is_array($error['status'] ?? null) && is_string($error['status']['error'] ?? null) + ? $error['status']['error'] + : json_encode($error); throw CollectionCreationException::withReason( $collectionName, - $error['status']['error'] ?? json_encode($error) + is_string($errorMessage) ? $errorMessage : 'Unknown error' ); } @@ -138,7 +145,10 @@ public function upsert(array $entry, string $project = 'default'): bool if (! $response->successful()) { $error = $response->json(); - throw UpsertException::withReason($error['status']['error'] ?? json_encode($error)); + $errorMessage = is_array($error) && is_array($error['status'] ?? null) && is_string($error['status']['error'] ?? null) + ? $error['status']['error'] + : json_encode($error); + throw UpsertException::withReason(is_string($errorMessage) ? $errorMessage : 'Unknown error'); } return true; @@ -203,25 +213,27 @@ public function search( } $data = $response->json(); - $results = $data['result'] ?? []; + /** @var array> $results */ + $results = is_array($data) && is_array($data['result'] ?? null) ? $data['result'] : []; - return collect($results)->map(function (array $result) { - $payload = $result['payload'] ?? []; + return collect($results)->map(function (array $result): array { + /** @var array $payload */ + $payload = is_array($result['payload'] ?? null) ? $result['payload'] : []; return [ - 'id' => $result['id'], - 'score' => $result['score'] ?? 0.0, - 'title' => $payload['title'] ?? '', - 'content' => $payload['content'] ?? '', - 'tags' => $payload['tags'] ?? [], - 'category' => $payload['category'] ?? null, - 'module' => $payload['module'] ?? null, - 'priority' => $payload['priority'] ?? null, - 'status' => $payload['status'] ?? null, - 'confidence' => $payload['confidence'] ?? 0, - 'usage_count' => $payload['usage_count'] ?? 0, - 'created_at' => $payload['created_at'] ?? '', - 'updated_at' => $payload['updated_at'] ?? '', + 'id' => is_string($result['id'] ?? null) || is_int($result['id'] ?? null) ? $result['id'] : '', + 'score' => is_float($result['score'] ?? null) || is_int($result['score'] ?? null) ? (float) ($result['score'] ?? 0.0) : 0.0, + 'title' => is_string($payload['title'] ?? null) ? $payload['title'] : '', + 'content' => is_string($payload['content'] ?? null) ? $payload['content'] : '', + 'tags' => is_array($payload['tags'] ?? null) ? array_values(array_filter($payload['tags'], fn ($tag): bool => is_string($tag))) : [], + 'category' => is_string($payload['category'] ?? null) ? $payload['category'] : null, + 'module' => is_string($payload['module'] ?? null) ? $payload['module'] : null, + 'priority' => is_string($payload['priority'] ?? null) ? $payload['priority'] : null, + 'status' => is_string($payload['status'] ?? null) ? $payload['status'] : null, + 'confidence' => is_int($payload['confidence'] ?? null) ? $payload['confidence'] : 0, + 'usage_count' => is_int($payload['usage_count'] ?? null) ? $payload['usage_count'] : 0, + 'created_at' => is_string($payload['created_at'] ?? null) ? $payload['created_at'] : '', + 'updated_at' => is_string($payload['updated_at'] ?? null) ? $payload['updated_at'] : '', ]; }); } @@ -229,7 +241,7 @@ public function search( /** * Scroll/list all entries without requiring a search query. * - * @param array $filters + * @param array{tag?: string, category?: string, module?: string, priority?: string, status?: string} $filters * @return CollectionensureCollection($project); - $qdrantFilter = ! empty($filters) ? $this->buildFilter($filters) : null; + $qdrantFilter = $filters !== [] ? $this->buildFilter($filters) : null; $response = $this->connector->send( new ScrollPoints( @@ -271,24 +283,28 @@ public function scroll( } $data = $response->json(); - $points = $data['result']['points'] ?? []; + /** @var array> $points */ + $points = is_array($data) && is_array($data['result'] ?? null) && is_array($data['result']['points'] ?? null) + ? $data['result']['points'] + : []; - return collect($points)->map(function (array $point) { - $payload = $point['payload'] ?? []; + return collect($points)->map(function (array $point): array { + /** @var array $payload */ + $payload = is_array($point['payload'] ?? null) ? $point['payload'] : []; return [ - 'id' => $point['id'], - 'title' => $payload['title'] ?? '', - 'content' => $payload['content'] ?? '', - 'tags' => $payload['tags'] ?? [], - 'category' => $payload['category'] ?? null, - 'module' => $payload['module'] ?? null, - 'priority' => $payload['priority'] ?? null, - 'status' => $payload['status'] ?? null, - 'confidence' => $payload['confidence'] ?? 0, - 'usage_count' => $payload['usage_count'] ?? 0, - 'created_at' => $payload['created_at'] ?? '', - 'updated_at' => $payload['updated_at'] ?? '', + 'id' => is_string($point['id'] ?? null) || is_int($point['id'] ?? null) ? $point['id'] : '', + 'title' => is_string($payload['title'] ?? null) ? $payload['title'] : '', + 'content' => is_string($payload['content'] ?? null) ? $payload['content'] : '', + 'tags' => is_array($payload['tags'] ?? null) ? array_values(array_filter($payload['tags'], fn ($tag): bool => is_string($tag))) : [], + 'category' => is_string($payload['category'] ?? null) ? $payload['category'] : null, + 'module' => is_string($payload['module'] ?? null) ? $payload['module'] : null, + 'priority' => is_string($payload['priority'] ?? null) ? $payload['priority'] : null, + 'status' => is_string($payload['status'] ?? null) ? $payload['status'] : null, + 'confidence' => is_int($payload['confidence'] ?? null) ? $payload['confidence'] : 0, + 'usage_count' => is_int($payload['usage_count'] ?? null) ? $payload['usage_count'] : 0, + 'created_at' => is_string($payload['created_at'] ?? null) ? $payload['created_at'] : '', + 'updated_at' => is_string($payload['updated_at'] ?? null) ? $payload['updated_at'] : '', ]; }); } @@ -340,28 +356,31 @@ public function getById(string|int $id, string $project = 'default'): ?array } $data = $response->json(); - $points = $data['result'] ?? []; + /** @var array> $points */ + $points = is_array($data) && is_array($data['result'] ?? null) ? $data['result'] : []; - if (empty($points)) { + if ($points === []) { return null; } + /** @var array $point */ $point = $points[0]; - $payload = $point['payload'] ?? []; + /** @var array $payload */ + $payload = is_array($point['payload'] ?? null) ? $point['payload'] : []; return [ - 'id' => $point['id'], - 'title' => $payload['title'] ?? '', - 'content' => $payload['content'] ?? '', - 'tags' => $payload['tags'] ?? [], - 'category' => $payload['category'] ?? null, - 'module' => $payload['module'] ?? null, - 'priority' => $payload['priority'] ?? null, - 'status' => $payload['status'] ?? null, - 'confidence' => $payload['confidence'] ?? 0, - 'usage_count' => $payload['usage_count'] ?? 0, - 'created_at' => $payload['created_at'] ?? '', - 'updated_at' => $payload['updated_at'] ?? '', + 'id' => is_string($point['id'] ?? null) || is_int($point['id'] ?? null) ? $point['id'] : '', + 'title' => is_string($payload['title'] ?? null) ? $payload['title'] : '', + 'content' => is_string($payload['content'] ?? null) ? $payload['content'] : '', + 'tags' => is_array($payload['tags'] ?? null) ? $payload['tags'] : [], + 'category' => is_string($payload['category'] ?? null) ? $payload['category'] : null, + 'module' => is_string($payload['module'] ?? null) ? $payload['module'] : null, + 'priority' => is_string($payload['priority'] ?? null) ? $payload['priority'] : null, + 'status' => is_string($payload['status'] ?? null) ? $payload['status'] : null, + 'confidence' => is_int($payload['confidence'] ?? null) ? $payload['confidence'] : 0, + 'usage_count' => is_int($payload['usage_count'] ?? null) ? $payload['usage_count'] : 0, + 'created_at' => is_string($payload['created_at'] ?? null) ? $payload['created_at'] : '', + 'updated_at' => is_string($payload['updated_at'] ?? null) ? $payload['updated_at'] : '', ]; } @@ -483,7 +502,13 @@ public function count(string $project = 'default'): int $data = $response->json(); - return $data['result']['points_count'] ?? 0; + if (! is_array($data) || ! is_array($data['result'] ?? null)) { + return 0; + } + + $pointsCount = $data['result']['points_count'] ?? 0; + + return is_int($pointsCount) ? $pointsCount : 0; } /** diff --git a/config/search.php b/config/search.php index f6992e9..650ef2f 100644 --- a/config/search.php +++ b/config/search.php @@ -63,7 +63,7 @@ 'enabled' => env('OLLAMA_ENABLED', true), 'host' => env('OLLAMA_HOST', 'localhost'), 'port' => env('OLLAMA_PORT', 11434), - 'model' => env('OLLAMA_MODEL', 'llama3.2:3b'), + 'model' => env('OLLAMA_MODEL', 'mistral:7b'), 'timeout' => env('OLLAMA_TIMEOUT', 30), 'auto_tag' => env('OLLAMA_AUTO_TAG', true), 'auto_categorize' => env('OLLAMA_AUTO_CATEGORIZE', true), diff --git a/docker-compose.odin.yml b/docker-compose.odin.yml index ee2a66c..135c412 100644 --- a/docker-compose.odin.yml +++ b/docker-compose.odin.yml @@ -12,7 +12,7 @@ services: - QDRANT__SERVICE__HTTP_PORT=6333 - QDRANT__SERVICE__GRPC_PORT=6334 healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"] + test: ["CMD-SHELL", "timeout 5 bash -c '\\|int\\|string\\|null\\>\\|null given\\.$#" count: 1 path: app/Commands/KnowledgeShowCommand.php - - - message: "#^Only booleans are allowed in an if condition, string\\|null given\\.$#" - count: 1 - path: app/Commands/KnowledgeShowCommand.php - - - - message: "#^Parameter \\#1 \\$id of method App\\\\Services\\\\QdrantService\\:\\:getById\\(\\) expects int\\|string, array\\|bool\\|string\\|null given\\.$#" - count: 1 - path: app/Commands/KnowledgeShowCommand.php - - - - message: "#^Parameter \\#1 \\$id of method App\\\\Services\\\\QdrantService\\:\\:incrementUsage\\(\\) expects int\\|string, array\\|bool\\|string\\|null given\\.$#" - count: 1 - path: app/Commands/KnowledgeShowCommand.php - - message: "#^Parameter \\#1 \\$id of method App\\\\Services\\\\QdrantService\\:\\:getById\\(\\) expects int\\|string, array\\|bool\\|string\\|null given\\.$#" count: 1 @@ -60,26 +40,6 @@ parameters: count: 2 path: app/Commands/KnowledgeValidateCommand.php - - - message: "#^Method App\\\\Commands\\\\Service\\\\StatusCommand\\:\\:getContainerStatus\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Commands/Service/StatusCommand.php - - - - message: "#^Method App\\\\Commands\\\\Service\\\\StatusCommand\\:\\:performHealthChecks\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Commands/Service/StatusCommand.php - - - - message: "#^Method App\\\\Commands\\\\Service\\\\StatusCommand\\:\\:renderDashboard\\(\\) has parameter \\$containers with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Commands/Service/StatusCommand.php - - - - message: "#^Method App\\\\Commands\\\\Service\\\\StatusCommand\\:\\:renderDashboard\\(\\) has parameter \\$healthData with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Commands/Service/StatusCommand.php - - message: "#^Only booleans are allowed in a negated boolean, array\\|bool\\|string\\|null given\\.$#" count: 2 @@ -105,16 +65,6 @@ parameters: count: 1 path: app/Commands/SyncCommand.php - - - message: "#^Method App\\\\Contracts\\\\FullTextSearchInterface\\:\\:searchObservations\\(\\) has invalid return type App\\\\Models\\\\Observation\\.$#" - count: 1 - path: app/Contracts/FullTextSearchInterface.php - - - - message: "#^Type App\\\\Models\\\\Observation in generic type Illuminate\\\\Database\\\\Eloquent\\\\Collection\\ in PHPDoc tag @return is not subtype of template type TModel of Illuminate\\\\Database\\\\Eloquent\\\\Model of class Illuminate\\\\Database\\\\Eloquent\\\\Collection\\.$#" - count: 1 - path: app/Contracts/FullTextSearchInterface.php - - message: "#^Only booleans are allowed in an if condition, string\\|null given\\.$#" count: 1 @@ -150,269 +100,14 @@ parameters: count: 1 path: app/Integrations/Qdrant/Requests/UpsertPoints.php - - - message: "#^Class App\\\\Models\\\\Observation not found\\.$#" - count: 1 - path: app/Models/Session.php - - - - message: "#^Method App\\\\Models\\\\Session\\:\\:observations\\(\\) has invalid return type App\\\\Models\\\\Observation\\.$#" - count: 1 - path: app/Models/Session.php - - - - message: "#^Method App\\\\Models\\\\Session\\:\\:observations\\(\\) should return Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\HasMany\\ but returns Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\HasMany\\\\.$#" - count: 1 - path: app/Models/Session.php - - - - message: "#^Parameter \\#1 \\$related of method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:hasMany\\(\\) expects class\\-string\\, string given\\.$#" - count: 1 - path: app/Models/Session.php - - - - message: "#^Type App\\\\Models\\\\Observation in generic type Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\HasMany\\ in PHPDoc tag @return is not subtype of template type TRelatedModel of Illuminate\\\\Database\\\\Eloquent\\\\Model of class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\HasMany\\.$#" - count: 1 - path: app/Models/Session.php - - - - message: "#^Unable to resolve the template type TRelatedModel in call to method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:hasMany\\(\\)$#" - count: 1 - path: app/Models/Session.php - - - - message: "#^Class App\\\\Models\\\\Entry not found\\.$#" - count: 1 - path: app/Models/Tag.php - - - - message: "#^Method App\\\\Models\\\\Tag\\:\\:entries\\(\\) has invalid return type App\\\\Models\\\\Entry\\.$#" - count: 1 - path: app/Models/Tag.php - - - - message: "#^Method App\\\\Models\\\\Tag\\:\\:entries\\(\\) should return Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\ but returns Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\\\.$#" - count: 1 - path: app/Models/Tag.php - - - - message: "#^Parameter \\#1 \\$related of method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:belongsToMany\\(\\) expects class\\-string\\, string given\\.$#" - count: 1 - path: app/Models/Tag.php - - - - message: "#^Type App\\\\Models\\\\Entry in generic type Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\ in PHPDoc tag @return is not subtype of template type TRelatedModel of Illuminate\\\\Database\\\\Eloquent\\\\Model of class Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\BelongsToMany\\.$#" - count: 1 - path: app/Models/Tag.php - - - - message: "#^Unable to resolve the template type TRelatedModel in call to method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:belongsToMany\\(\\)$#" - count: 1 - path: app/Models/Tag.php - - - - message: "#^Class App\\\\Services\\\\ChromaDBIndexService not found\\.$#" - count: 1 - path: app/Providers/AppServiceProvider.php - - - - message: "#^Class App\\\\Services\\\\SemanticSearchService not found\\.$#" - count: 1 - path: app/Providers/AppServiceProvider.php - - - - message: "#^Instantiated class App\\\\Services\\\\ChromaDBIndexService not found\\.$#" - count: 1 - path: app/Providers/AppServiceProvider.php - - - - message: "#^Instantiated class App\\\\Services\\\\SemanticSearchService not found\\.$#" - count: 1 - path: app/Providers/AppServiceProvider.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:analyzeIssue\\(\\) has parameter \\$issue with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:analyzeIssue\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:buildTodoList\\(\\) has parameter \\$analysis with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:buildTodoList\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:extractKeywords\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:gatherCodebaseContext\\(\\) has parameter \\$issue with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:gatherCodebaseContext\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:groupFilesByChangeType\\(\\) has parameter \\$files with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:groupFilesByChangeType\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:searchFiles\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:validateAndEnhanceAnalysis\\(\\) has parameter \\$analysis with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - - - message: "#^Method App\\\\Services\\\\IssueAnalyzerService\\:\\:validateAndEnhanceAnalysis\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/IssueAnalyzerService.php - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" - count: 3 - path: app/Services/MarkdownExporter.php - - - - message: "#^Call to static method query\\(\\) on an unknown class App\\\\Models\\\\Observation\\.$#" - count: 3 - path: app/Services/ObservationService.php - - - - message: "#^Method App\\\\Services\\\\ObservationService\\:\\:createObservation\\(\\) has invalid return type App\\\\Models\\\\Observation\\.$#" - count: 1 - path: app/Services/ObservationService.php - - - - message: "#^Method App\\\\Services\\\\ObservationService\\:\\:getObservationsByType\\(\\) has invalid return type App\\\\Models\\\\Observation\\.$#" - count: 1 - path: app/Services/ObservationService.php - - - - message: "#^Method App\\\\Services\\\\ObservationService\\:\\:getRecentObservations\\(\\) has invalid return type App\\\\Models\\\\Observation\\.$#" count: 1 - path: app/Services/ObservationService.php - - - - message: "#^Method App\\\\Services\\\\ObservationService\\:\\:searchObservations\\(\\) has invalid return type App\\\\Models\\\\Observation\\.$#" - count: 1 - path: app/Services/ObservationService.php - - - - message: "#^PHPDoc tag @var contains unknown class App\\\\Models\\\\Observation\\.$#" - count: 2 - path: app/Services/ObservationService.php - - - - message: "#^Type App\\\\Models\\\\Observation in generic type Illuminate\\\\Database\\\\Eloquent\\\\Collection\\ in PHPDoc tag @return is not subtype of template type TModel of Illuminate\\\\Database\\\\Eloquent\\\\Model of class Illuminate\\\\Database\\\\Eloquent\\\\Collection\\.$#" - count: 3 - path: app/Services/ObservationService.php - - - - message: "#^Type App\\\\Models\\\\Observation in generic type Illuminate\\\\Database\\\\Eloquent\\\\Collection\\ in PHPDoc tag @var is not subtype of template type TModel of Illuminate\\\\Database\\\\Eloquent\\\\Model of class Illuminate\\\\Database\\\\Eloquent\\\\Collection\\.$#" - count: 2 - path: app/Services/ObservationService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:analyzeIssue\\(\\) has parameter \\$codebaseContext with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:analyzeIssue\\(\\) has parameter \\$issue with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:analyzeIssue\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:analyzeTestFailure\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:buildIssueAnalysisPrompt\\(\\) has parameter \\$codebaseContext with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:buildIssueAnalysisPrompt\\(\\) has parameter \\$issue with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:enhanceEntry\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:expandQuery\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:extractConcepts\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:extractTags\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:parseEnhancementResponse\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:parseIssueAnalysisResponse\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:suggestCodeChanges\\(\\) has parameter \\$issue with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php - - - - message: "#^Method App\\\\Services\\\\OllamaService\\:\\:suggestCodeChanges\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/OllamaService.php + path: app/Services/MarkdownExporter.php - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" - count: 3 - path: app/Services/QdrantService.php - - - - message: "#^Method App\\\\Services\\\\QdrantService\\:\\:search\\(\\) should return Illuminate\\\\Support\\\\Collection\\, category\\: string\\|null, module\\: string\\|null, priority\\: string\\|null, \\.\\.\\.\\}\\> but returns Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\), array\\{id\\: mixed, score\\: mixed, title\\: mixed, content\\: mixed, tags\\: mixed, category\\: mixed, module\\: mixed, priority\\: mixed, \\.\\.\\.\\}\\>\\.$#" - count: 1 + count: 2 path: app/Services/QdrantService.php - @@ -429,163 +124,3 @@ parameters: 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, \\.\\.\\.\\}, non\\-empty\\-array\\ given\\.$#" count: 1 path: app/Services/QdrantService.php - - - - message: "#^Unable to resolve the template type TKey in call to function collect$#" - count: 1 - path: app/Services/QdrantService.php - - - - message: "#^Unable to resolve the template type TValue in call to function collect$#" - count: 1 - path: app/Services/QdrantService.php - - - - message: "#^Access to property \\$id on an unknown class App\\\\Models\\\\Observation\\.$#" - count: 1 - path: app/Services/SQLiteFtsService.php - - - - message: "#^Call to static method query\\(\\) on an unknown class App\\\\Models\\\\Observation\\.$#" - count: 1 - path: app/Services/SQLiteFtsService.php - - - - message: "#^Method App\\\\Services\\\\SQLiteFtsService\\:\\:searchObservations\\(\\) has invalid return type App\\\\Models\\\\Observation\\.$#" - count: 1 - path: app/Services/SQLiteFtsService.php - - - - message: "#^PHPDoc tag @var for variable \\$observations contains unknown class App\\\\Models\\\\Observation\\.$#" - count: 1 - path: app/Services/SQLiteFtsService.php - - - - message: "#^Type App\\\\Models\\\\Observation in generic type Illuminate\\\\Database\\\\Eloquent\\\\Collection\\ in PHPDoc tag @return is not subtype of template type TModel of Illuminate\\\\Database\\\\Eloquent\\\\Model of class Illuminate\\\\Database\\\\Eloquent\\\\Collection\\.$#" - count: 1 - path: app/Services/SQLiteFtsService.php - - - - message: "#^Type App\\\\Models\\\\Observation in generic type Illuminate\\\\Database\\\\Eloquent\\\\Collection\\ in PHPDoc tag @var for variable \\$observations is not subtype of template type TModel of Illuminate\\\\Database\\\\Eloquent\\\\Model of class Illuminate\\\\Database\\\\Eloquent\\\\Collection\\.$#" - count: 1 - path: app/Services/SQLiteFtsService.php - - - - message: "#^Dynamic call to static method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\\\:\\:where\\(\\)\\.$#" - count: 1 - path: app/Services/SessionService.php - - - - message: "#^Dynamic call to static method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:orderBy\\(\\)\\.$#" - count: 2 - path: app/Services/SessionService.php - - - - message: "#^Dynamic call to static method Illuminate\\\\Database\\\\Query\\\\Builder\\:\\:whereNull\\(\\)\\.$#" - count: 1 - path: app/Services/SessionService.php - - - - message: "#^Method App\\\\Services\\\\SessionService\\:\\:getSessionObservations\\(\\) has invalid return type App\\\\Models\\\\Observation\\.$#" - count: 1 - path: app/Services/SessionService.php - - - - message: "#^PHPDoc tag @var contains unknown class App\\\\Models\\\\Observation\\.$#" - count: 2 - path: app/Services/SessionService.php - - - - message: "#^Type App\\\\Models\\\\Observation in generic type Illuminate\\\\Database\\\\Eloquent\\\\Collection\\ in PHPDoc tag @return is not subtype of template type TModel of Illuminate\\\\Database\\\\Eloquent\\\\Model of class Illuminate\\\\Database\\\\Eloquent\\\\Collection\\.$#" - count: 1 - path: app/Services/SessionService.php - - - - message: "#^Type App\\\\Models\\\\Observation in generic type Illuminate\\\\Database\\\\Eloquent\\\\Collection\\ in PHPDoc tag @var is not subtype of template type TModel of Illuminate\\\\Database\\\\Eloquent\\\\Model of class Illuminate\\\\Database\\\\Eloquent\\\\Collection\\.$#" - count: 2 - path: app/Services/SessionService.php - - - - message: "#^Method App\\\\Services\\\\StubFtsService\\:\\:searchObservations\\(\\) has invalid return type App\\\\Models\\\\Observation\\.$#" - count: 1 - path: app/Services/StubFtsService.php - - - - message: "#^Type App\\\\Models\\\\Observation in generic type Illuminate\\\\Database\\\\Eloquent\\\\Collection\\ in PHPDoc tag @return is not subtype of template type TModel of Illuminate\\\\Database\\\\Eloquent\\\\Model of class Illuminate\\\\Database\\\\Eloquent\\\\Collection\\.$#" - count: 1 - path: app/Services/StubFtsService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:execute\\(\\) has parameter \\$issue with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:execute\\(\\) has parameter \\$todos with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:execute\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:executeImplementation\\(\\) has parameter \\$issue with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:executeImplementation\\(\\) has parameter \\$todo with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:executeImplementation\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:executeQuality\\(\\) has parameter \\$todo with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:executeQuality\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:executeTest\\(\\) has parameter \\$issue with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:executeTest\\(\\) has parameter \\$todo with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:executeTest\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:getCompletedTodos\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Method App\\\\Services\\\\TodoExecutorService\\:\\:getFailedTodos\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Property App\\\\Services\\\\TodoExecutorService\\:\\:\\$completedTodos type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php - - - - message: "#^Property App\\\\Services\\\\TodoExecutorService\\:\\:\\$failedTodos type has no value type specified in iterable type array\\.$#" - count: 1 - path: app/Services/TodoExecutorService.php diff --git a/phpstan.neon b/phpstan.neon index 5ce6ee3..dc51fd2 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -15,7 +15,9 @@ parameters: message: '#does not specify its types: TFactory#' paths: - app/Models/* + reportUnmatched: false - message: '#Only booleans are allowed in (an if condition|&&)#' paths: - - app/Services/* \ No newline at end of file + - app/Services/* + reportUnmatched: false \ No newline at end of file diff --git a/pint.json b/pint.json index 661e522..307a5ea 100644 --- a/pint.json +++ b/pint.json @@ -1,3 +1,8 @@ { - "preset": "laravel" -} \ No newline at end of file + "preset": "laravel", + "paths": [ + "app", + "config", + "tests" + ] +} diff --git a/tests/Feature/AppServiceProviderTest.php b/tests/Feature/AppServiceProviderTest.php index 3e91485..8fbb644 100644 --- a/tests/Feature/AppServiceProviderTest.php +++ b/tests/Feature/AppServiceProviderTest.php @@ -88,11 +88,6 @@ expect($service)->toBeInstanceOf(QdrantService::class); }); - - - - - it('uses custom embedding server configuration for qdrant provider', function (): void { config([ 'search.embedding_provider' => 'qdrant',