From 9788b1852066c5d0d10e3cb15dee93432bc0c8fa Mon Sep 17 00:00:00 2001 From: Agent Date: Tue, 10 Feb 2026 19:53:40 +0000 Subject: [PATCH] feat: implement superseded marking instead of overwrite/delete (#100) When duplicate entries are detected during `know add`, old entries are now marked as superseded (with superseded_by, superseded_date, and superseded_reason) rather than being silently overwritten or deleted. - Add duplicate detection to QdrantService::upsert() using content hash and vector similarity (0.95 threshold) - Add markSuperseded(), findSimilar(), and getSupersessionHistory() to QdrantService - Exclude superseded entries from default search via Qdrant is_empty filter; add --include-superseded flag to search command - Show [SUPERSEDED] indicator and supersession history in show command - Prompt for user confirmation on similarity duplicates; warn when confidence is below 70% - Add --force flag to skip duplicate detection - Add comprehensive unit and feature tests for all supersession flows --- app/Commands/KnowledgeAddCommand.php | 104 +++- app/Commands/KnowledgeSearchCommand.php | 20 +- app/Commands/KnowledgeShowCommand.php | 65 ++- app/Services/QdrantService.php | 338 ++++++++---- phpstan-baseline.neon | 42 +- .../Commands/KnowledgeAddCommandTest.php | 6 +- .../Commands/KnowledgeShowCommandTest.php | 144 +++++ tests/Feature/KnowledgeAddCommandTest.php | 101 ++++ tests/Feature/KnowledgeSearchCommandTest.php | 71 +++ tests/Unit/Services/QdrantServiceTest.php | 512 +++++++++++++++++- 10 files changed, 1256 insertions(+), 147 deletions(-) diff --git a/app/Commands/KnowledgeAddCommand.php b/app/Commands/KnowledgeAddCommand.php index 33294b4..86a8056 100644 --- a/app/Commands/KnowledgeAddCommand.php +++ b/app/Commands/KnowledgeAddCommand.php @@ -178,19 +178,103 @@ public function handle(GitContextService $gitService, QdrantService $qdrant, Wri return self::FAILURE; } } catch (DuplicateEntryException $e) { - if ($e->duplicateType === DuplicateEntryException::TYPE_HASH) { - error("Duplicate content detected: This exact content already exists as entry '{$e->existingId}'"); - } else { - $percentage = $e->similarityScore !== null ? round($e->similarityScore * 100, 1) : 95; - warning("Potential duplicate detected: {$percentage}% similar to existing entry '{$e->existingId}'"); - error('Entry not created. Use --force to override duplicate detection.'); - } + return $this->handleDuplicate($e, $data, $qdrant, (int) $confidence); + } + + info('Knowledge entry created!'); + + $this->displayEntryTable($id, $title, $category, $priority, (int) $confidence, $data['tags'] ?? null); + + return self::SUCCESS; + } + + /** + * Handle a duplicate entry by offering to supersede or aborting. + * + * @param array $data + */ + private function handleDuplicate( + DuplicateEntryException $e, + array $data, + QdrantService $qdrant, + int $confidence + ): int { + $existingId = $e->existingId; + + if ($e->duplicateType === DuplicateEntryException::TYPE_HASH) { + error("Duplicate content detected: This exact content already exists as entry '{$existingId}'"); return self::FAILURE; } - info('Knowledge entry created!'); + $percentage = $e->similarityScore !== null ? round($e->similarityScore * 100, 1) : 95; + warning("Potential duplicate detected: {$percentage}% similar to existing entry '{$existingId}'"); + + // Require confirmation when confidence is low (below 70) + if ($confidence < 70) { + warning("Low confidence ({$confidence}%) - please confirm this supersedes the existing entry."); + } + + $shouldSupersede = $this->confirm( + "Supersede existing entry '{$existingId}' with this new entry?", + $confidence >= 70 + ); + + if (! $shouldSupersede) { + error('Entry not created. Existing knowledge preserved.'); + + return self::FAILURE; + } + + // Force-create the new entry (skip duplicate check) + $success = spin( + fn (): bool => $qdrant->upsert($data, 'default', false), + 'Storing new knowledge entry...' + ); + + if (! $success) { + error('Failed to create knowledge entry'); + + return self::FAILURE; + } + // Mark the old entry as superseded + $reason = "Superseded by newer entry with {$percentage}% similarity"; + $marked = $qdrant->markSuperseded($existingId, $data['id'], $reason); + + if (! $marked) { + warning('New entry created but failed to mark old entry as superseded.'); + } + + info('Knowledge entry created! Previous entry marked as superseded.'); + + /** @var string $id */ + $id = $data['id']; + /** @var string $title */ + $title = $data['title']; + /** @var string|null $category */ + $category = $data['category'] ?? null; + /** @var string $priority */ + $priority = $data['priority'] ?? 'medium'; + + $this->displayEntryTable($id, $title, $category, $priority, $confidence, $data['tags'] ?? null); + + return self::SUCCESS; + } + + /** + * Display the entry summary table. + * + * @param array|null $tags + */ + private function displayEntryTable( + string $id, + string $title, + ?string $category, + string $priority, + int $confidence, + ?array $tags + ): void { table( ['Field', 'Value'], [ @@ -199,10 +283,8 @@ public function handle(GitContextService $gitService, QdrantService $qdrant, Wri ['Category', $category ?? 'N/A'], ['Priority', $priority], ['Confidence', "{$confidence}%"], - ['Tags', isset($data['tags']) ? implode(', ', $data['tags']) : 'N/A'], + ['Tags', $tags !== null ? implode(', ', $tags) : 'N/A'], ] ); - - return self::SUCCESS; } } diff --git a/app/Commands/KnowledgeSearchCommand.php b/app/Commands/KnowledgeSearchCommand.php index 24dda51..e764f7e 100644 --- a/app/Commands/KnowledgeSearchCommand.php +++ b/app/Commands/KnowledgeSearchCommand.php @@ -21,7 +21,8 @@ class KnowledgeSearchCommand extends Command {--priority= : Filter by priority} {--status= : Filter by status} {--limit=20 : Maximum number of results} - {--semantic : Use semantic search if available}'; + {--semantic : Use semantic search if available} + {--include-superseded : Include superseded entries in results}'; /** * @var string @@ -38,6 +39,7 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i $status = $this->option('status'); $limit = (int) $this->option('limit'); $this->option('semantic'); + $includeSuperseded = (bool) $this->option('include-superseded'); // Require at least one search parameter for entries if ($query === null && $tag === null && $category === null && $module === null && $priority === null && $status === null) { @@ -55,6 +57,10 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i 'status' => is_string($status) ? $status : null, ]); + if ($includeSuperseded) { + $filters['include_superseded'] = true; + } + // Use Qdrant for semantic search (always) $searchQuery = is_string($query) ? $query : ''; $results = $qdrant->search($searchQuery, $filters, $limit); @@ -78,12 +84,18 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i $tags = $entry['tags'] ?? []; $content = $entry['content'] ?? ''; $score = $entry['score'] ?? 0.0; + $supersededBy = $entry['superseded_by'] ?? null; $isStale = $metadata->isStale($entry); $effectiveConfidence = $metadata->calculateEffectiveConfidence($entry); $confidenceLevel = $metadata->confidenceLevel($effectiveConfidence); - $this->line("[{$id}] {$title} (score: ".number_format($score, 2).')'); + $titleLine = "[{$id}] {$title} (score: ".number_format($score, 2).')'; + if ($supersededBy !== null) { + $titleLine .= ' [SUPERSEDED]'; + } + + $this->line($titleLine); if ($isStale) { $days = $metadata->daysSinceVerification($entry); @@ -92,6 +104,10 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i $this->line('Category: '.$category." | Priority: {$priority} | Confidence: {$effectiveConfidence}% ({$confidenceLevel})"); + if ($supersededBy !== null) { + $this->line("Superseded by: {$supersededBy}"); + } + if ($module !== null) { $this->line("Module: {$module}"); } diff --git a/app/Commands/KnowledgeShowCommand.php b/app/Commands/KnowledgeShowCommand.php index 0aa51ff..2210b69 100644 --- a/app/Commands/KnowledgeShowCommand.php +++ b/app/Commands/KnowledgeShowCommand.php @@ -27,6 +27,12 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i $id = (int) $id; } + if (! is_string($id) && ! is_int($id)) { + error('Invalid entry ID.'); + + return self::FAILURE; + } + $entry = spin( fn (): ?array => $qdrant->getById($id), 'Fetching entry...' @@ -42,13 +48,26 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i $this->renderEntry($entry, $metadata); + // Show supersession history + $history = $qdrant->getSupersessionHistory($id); + $this->renderSupersessionHistory($entry, $history); + return self::SUCCESS; } + /** + * @param array $entry + */ private function renderEntry(array $entry, EntryMetadataService $metadata): void { $this->newLine(); - $this->line("{$entry['title']}"); + + $titleLine = "{$entry['title']}"; + $supersededBy = $entry['superseded_by'] ?? null; + if ($supersededBy !== null && $supersededBy !== '') { + $titleLine .= ' [SUPERSEDED]'; + } + $this->line($titleLine); $this->line("ID: {$entry['id']}"); $this->newLine(); @@ -76,12 +95,20 @@ private function renderEntry(array $entry, EntryMetadataService $metadata): void ['Evidence', $entry['evidence'] ?? 'N/A'], ]; - if ($entry['module']) { + if ($entry['module'] !== null) { $rows[] = ['Module', $entry['module']]; } - if (! empty($entry['tags'])) { - $rows[] = ['Tags', implode(', ', $entry['tags'])]; + /** @var array $tags */ + $tags = $entry['tags'] ?? []; + if ($tags !== []) { + $rows[] = ['Tags', implode(', ', $tags)]; + } + + if ($supersededBy !== null && $supersededBy !== '') { + $rows[] = ['Superseded By', $supersededBy]; + $rows[] = ['Superseded Date', $entry['superseded_date'] ?? 'N/A']; + $rows[] = ['Superseded Reason', $entry['superseded_reason'] ?? 'N/A']; } table(['Field', 'Value'], $rows); @@ -90,6 +117,36 @@ private function renderEntry(array $entry, EntryMetadataService $metadata): void $this->line("Created: {$entry['created_at']} | Updated: {$entry['updated_at']}"); } + /** + * @param array $entry + * @param array{supersedes: array>, superseded_by: array|null} $history + */ + private function renderSupersessionHistory(array $entry, array $history): void + { + $hasHistory = $history['supersedes'] !== [] || $history['superseded_by'] !== null; + + if (! $hasHistory) { + return; + } + + $this->newLine(); + $this->line('Supersession History'); + + if ($history['superseded_by'] !== null) { + $successor = $history['superseded_by']; + $this->line(' This entry was superseded by:'); + $this->line(" [{$successor['id']}] {$successor['title']}"); + } + + if ($history['supersedes'] !== []) { + $this->line(' This entry supersedes:'); + foreach ($history['supersedes'] as $predecessor) { + $reason = $predecessor['superseded_reason'] ?? 'No reason provided'; + $this->line(" [{$predecessor['id']}] {$predecessor['title']} ({$reason})"); + } + } + } + private function colorize(string $text, string $color): string { return "{$text}"; diff --git a/app/Services/QdrantService.php b/app/Services/QdrantService.php index 4bd5c68..c5d59e3 100644 --- a/app/Services/QdrantService.php +++ b/app/Services/QdrantService.php @@ -8,6 +8,7 @@ use App\Contracts\SparseEmbeddingServiceInterface; use App\Exceptions\Qdrant\CollectionCreationException; use App\Exceptions\Qdrant\ConnectionException; +use App\Exceptions\Qdrant\DuplicateEntryException; use App\Exceptions\Qdrant\EmbeddingException; use App\Exceptions\Qdrant\UpsertException; use App\Integrations\Qdrant\QdrantConnector; @@ -117,8 +118,13 @@ public function ensureCollection(string $project = 'default'): bool * created_at?: string, * updated_at?: string, * last_verified?: string|null, - * evidence?: string|null + * evidence?: string|null, + * superseded_by?: string, + * superseded_date?: string, + * superseded_reason?: string * } $entry + * + * @throws DuplicateEntryException */ public function upsert(array $entry, string $project = 'default', bool $checkDuplicates = true): bool { @@ -132,6 +138,24 @@ public function upsert(array $entry, string $project = 'default', bool $checkDup throw EmbeddingException::generationFailed($text); } + // Check for duplicates when requested (for new entries) + if ($checkDuplicates) { + $contentHash = hash('sha256', $entry['title'].$entry['content']); + $similar = $this->findSimilar($vector, $project, 0.95); + + foreach ($similar as $existing) { + $existingHash = hash('sha256', $existing['title'].$existing['content']); + if ($existingHash === $contentHash) { + throw DuplicateEntryException::hashMatch($existing['id'], $contentHash); + } + } + + if ($similar->isNotEmpty()) { + $topMatch = $similar->first(); + throw DuplicateEntryException::similarityMatch($topMatch['id'], $topMatch['score']); + } + } + // Store full entry data in payload $payload = [ 'title' => $entry['title'], @@ -147,6 +171,9 @@ public function upsert(array $entry, string $project = 'default', bool $checkDup 'updated_at' => $entry['updated_at'] ?? now()->toIso8601String(), 'last_verified' => $entry['last_verified'] ?? null, 'evidence' => $entry['evidence'] ?? null, + 'superseded_by' => $entry['superseded_by'] ?? null, + 'superseded_date' => $entry['superseded_date'] ?? null, + 'superseded_reason' => $entry['superseded_reason'] ?? null, ]; // Build point with appropriate vector format @@ -182,6 +209,123 @@ public function upsert(array $entry, string $project = 'default', bool $checkDup return true; } + /** + * Mark an existing entry as superseded by a new entry. + */ + public function markSuperseded( + string|int $existingId, + string|int $newId, + string $reason = 'Updated with newer knowledge', + string $project = 'default' + ): bool { + return $this->updateFields($existingId, [ + 'superseded_by' => (string) $newId, + 'superseded_date' => now()->toIso8601String(), + 'superseded_reason' => $reason, + ], $project); + } + + /** + * Find entries similar to the given vector above a threshold. + * + * @param array $vector + * @return Collection + */ + public function findSimilar(array $vector, string $project = 'default', float $threshold = 0.95): Collection + { + $this->ensureCollection($project); + + // Exclude already-superseded entries from duplicate detection + $filter = [ + 'must' => [ + [ + 'is_empty' => ['key' => 'superseded_by'], + ], + ], + ]; + + $response = $this->connector->send( + new SearchPoints( + $this->getCollectionName($project), + $vector, + 5, + $threshold, + $filter + ) + ); + + if (! $response->successful()) { + return collect(); + } + + $data = $response->json(); + $results = $data['result'] ?? []; + + return collect($results)->map(fn (array $result): array => [ + 'id' => $result['id'], + 'score' => $result['score'] ?? 0.0, + 'title' => $result['payload']['title'] ?? '', + 'content' => $result['payload']['content'] ?? '', + ]); + } + + /** + * Get supersession history for an entry (entries it superseded and entries that supersede it). + * + * @return array{supersedes: array>, superseded_by: array|null} + */ + public function getSupersessionHistory(string|int $id, string $project = 'default'): array + { + $history = [ + 'supersedes' => [], + 'superseded_by' => null, + ]; + + $entry = $this->getById($id, $project); + + if ($entry === null) { + return $history; + } + + // Check if this entry is superseded by another + $supersededBy = $entry['superseded_by'] ?? null; + if ($supersededBy !== null && $supersededBy !== '') { + $successor = $this->getById($supersededBy, $project); + if ($successor !== null) { + $history['superseded_by'] = $successor; + } + } + + // Find entries that this entry superseded (entries whose superseded_by == this id) + $this->ensureCollection($project); + $filter = [ + 'must' => [ + [ + 'key' => 'superseded_by', + 'match' => ['value' => (string) $id], + ], + ], + ]; + + $response = $this->connector->send( + new ScrollPoints( + $this->getCollectionName($project), + 100, + $filter, + null + ) + ); + + if ($response->successful()) { + $data = $response->json(); + $points = $data['result']['points'] ?? []; + + $history['supersedes'] = array_map(fn (array $point): array => $this->mapPointToEntry($point), $points); + } + + return $history; + } + /** * Search entries using semantic similarity. * @@ -190,7 +334,8 @@ public function upsert(array $entry, string $project = 'default', bool $checkDup * category?: string, * module?: string, * priority?: string, - * status?: string + * status?: string, + * include_superseded?: bool * } $filters * @return Collection */ public function search( @@ -244,7 +392,10 @@ public function search( * created_at: string, * updated_at: string, * last_verified: ?string, - * evidence: ?string + * evidence: ?string, + * superseded_by: ?string, + * superseded_date: ?string, + * superseded_reason: ?string * }> */ private function executeSearch( @@ -282,27 +433,7 @@ private function executeSearch( $data = $response->json(); $results = $data['result'] ?? []; - return collect($results)->map(function (array $result): array { - $payload = $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'] ?? '', - 'last_verified' => $payload['last_verified'] ?? null, - 'evidence' => $payload['evidence'] ?? null, - ]; - }); + return collect($results)->map(fn (array $result): array => $this->mapResultToEntry($result)); } /** @@ -332,7 +463,10 @@ private function executeSearch( * created_at: string, * updated_at: string, * last_verified: ?string, - * evidence: ?string + * evidence: ?string, + * superseded_by: ?string, + * superseded_date: ?string, + * superseded_reason: ?string * }> */ public function hybridSearch( @@ -385,27 +519,7 @@ public function hybridSearch( $data = $response->json(); $points = $data['result']['points'] ?? []; - return collect($points)->map(function (array $point): array { - $payload = $point['payload'] ?? []; - - return [ - 'id' => $point['id'], - 'score' => $point['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'] ?? '', - 'last_verified' => $payload['last_verified'] ?? null, - 'evidence' => $payload['evidence'] ?? null, - ]; - }); + return collect($points)->map(fn (array $point): array => $this->mapResultToEntry($point)); } /** @@ -457,26 +571,7 @@ public function scroll( $data = $response->json(); $points = $data['result']['points'] ?? []; - return collect($points)->map(function (array $point): array { - $payload = $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'] ?? '', - 'last_verified' => $payload['last_verified'] ?? null, - 'evidence' => $payload['evidence'] ?? null, - ]; - }); + return collect($points)->map(fn (array $point): array => $this->mapPointToEntry($point)); } /** @@ -518,7 +613,10 @@ public function delete(array $ids, string $project = 'default'): bool * created_at: string, * updated_at: string, * last_verified: ?string, - * evidence: ?string + * evidence: ?string, + * superseded_by: ?string, + * superseded_date: ?string, + * superseded_reason: ?string * }|null */ public function getById(string|int $id, string $project = 'default'): ?array @@ -536,29 +634,11 @@ public function getById(string|int $id, string $project = 'default'): ?array $data = $response->json(); $points = $data['result'] ?? []; - if (empty($points)) { + if ($points === []) { return null; } - $point = $points[0]; - $payload = $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'] ?? '', - 'last_verified' => $payload['last_verified'] ?? null, - 'evidence' => $payload['evidence'] ?? null, - ]; + return $this->mapPointToEntry($points[0]); } /** @@ -575,7 +655,7 @@ public function incrementUsage(string|int $id, string $project = 'default'): boo $entry['usage_count']++; $entry['updated_at'] = now()->toIso8601String(); - return $this->upsert($entry, $project); + return $this->upsert($entry, $project, false); } /** @@ -595,7 +675,7 @@ public function updateFields(string|int $id, array $fields, string $project = 'd $entry = array_merge($entry, $fields); $entry['updated_at'] = now()->toIso8601String(); - return $this->upsert($entry, $project); + return $this->upsert($entry, $project, false); } /** @@ -631,18 +711,25 @@ private function getCachedEmbedding(string $text): array * category?: string, * module?: string, * priority?: string, - * status?: string + * status?: string, + * include_superseded?: bool * } $filters * @return array|null */ private function buildFilter(array $filters): ?array { - if ($filters === []) { - return null; - } + $includeSuperseded = (bool) ($filters['include_superseded'] ?? false); + unset($filters['include_superseded']); $must = []; + // Exclude superseded entries by default + if (! $includeSuperseded) { + $must[] = [ + 'is_empty' => ['key' => 'superseded_by'], + ]; + } + // Exact match filters foreach (['category', 'module', 'priority', 'status'] as $field) { if (isset($filters[$field])) { @@ -664,6 +751,69 @@ private function buildFilter(array $filters): ?array return $must === [] ? null : ['must' => $must]; } + /** + * Map a Qdrant search result (with score) to an entry array. + * + * @param array $result + * @return array + */ + private function mapResultToEntry(array $result): array + { + $payload = $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'] ?? '', + 'last_verified' => $payload['last_verified'] ?? null, + 'evidence' => $payload['evidence'] ?? null, + 'superseded_by' => $payload['superseded_by'] ?? null, + 'superseded_date' => $payload['superseded_date'] ?? null, + 'superseded_reason' => $payload['superseded_reason'] ?? null, + ]; + } + + /** + * Map a Qdrant point (without score) to an entry array. + * + * @param array $point + * @return array + */ + private function mapPointToEntry(array $point): array + { + $payload = $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'] ?? '', + 'last_verified' => $payload['last_verified'] ?? null, + 'evidence' => $payload['evidence'] ?? null, + 'superseded_by' => $payload['superseded_by'] ?? null, + 'superseded_date' => $payload['superseded_date'] ?? null, + 'superseded_reason' => $payload['superseded_reason'] ?? null, + ]; + } + /** * Get the total count of entries in a collection. * diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3f9506f..e3d26ef 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -30,6 +30,11 @@ parameters: 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\\.$#" + count: 1 + path: app/Commands/KnowledgeAddCommand.php + - message: "#^Offset 'tags' on array\\{id\\: int\\|string, title\\: string, content\\: string, tags\\: array\\, category\\: string\\|null, module\\: string\\|null, priority\\: string\\|null, status\\: string\\|null, \\.\\.\\.\\} in isset\\(\\) always exists and is not nullable\\.$#" count: 1 @@ -45,31 +50,11 @@ parameters: count: 1 path: app/Commands/KnowledgeSearchStatusCommand.php - - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" - count: 1 - path: app/Commands/KnowledgeShowCommand.php - - - - message: "#^Method App\\\\Commands\\\\KnowledgeShowCommand\\:\\:renderEntry\\(\\) has parameter \\$entry with no value type specified in iterable type array\\.$#" - count: 1 - path: app/Commands/KnowledgeShowCommand.php - - message: "#^Only booleans are allowed in a negated boolean, array\\\\|int\\|string\\|null\\>\\|null given\\.$#" count: 1 path: app/Commands/KnowledgeShowCommand.php - - - message: "#^Parameter \\#1 \\$id of method App\\\\Services\\\\QdrantService\\:\\:getById\\(\\) expects int\\|string, array\\|bool\\|int\\|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\\|int\\|string\\|null given\\.$#" - count: 1 - path: app/Commands/KnowledgeShowCommand.php - - message: "#^Method App\\\\Commands\\\\KnowledgeStatsCommand\\:\\:renderDashboard\\(\\) has parameter \\$entries with generic class Illuminate\\\\Support\\\\Collection but does not specify its types\\: TKey, TValue$#" count: 1 @@ -181,22 +166,27 @@ parameters: path: app/Services/PatternDetectorService.php - - message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#" + message: "#^Method App\\\\Services\\\\QdrantService\\:\\:executeSearch\\(\\) 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\\:\\:executeSearch\\(\\) 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, \\.\\.\\.\\}\\>\\.$#" + 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\\:\\:hybridSearch\\(\\) 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, \\.\\.\\.\\}\\>\\.$#" + 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 - - 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\\{id\\: mixed, title\\: mixed, content\\: mixed, tags\\: mixed, category\\: mixed, module\\: mixed, priority\\: mixed, status\\: mixed, \\.\\.\\.\\}\\>\\.$#" + 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\\:\\: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 @@ -227,10 +217,10 @@ parameters: - message: "#^Unable to resolve the template type TKey in call to function collect$#" - count: 3 + count: 4 path: app/Services/QdrantService.php - message: "#^Unable to resolve the template type TValue in call to function collect$#" - count: 3 + count: 4 path: app/Services/QdrantService.php diff --git a/tests/Feature/Commands/KnowledgeAddCommandTest.php b/tests/Feature/Commands/KnowledgeAddCommandTest.php index 4632ff3..3899798 100644 --- a/tests/Feature/Commands/KnowledgeAddCommandTest.php +++ b/tests/Feature/Commands/KnowledgeAddCommandTest.php @@ -337,7 +337,7 @@ ->expectsOutputToContain('Duplicate content detected'); }); -it('fails when similar entry is detected', function (): void { +it('fails when similar entry is detected and user declines', function (): void { $this->mockQdrant->shouldReceive('upsert') ->once() ->andThrow(DuplicateEntryException::similarityMatch('similar-id-456', 0.97)); @@ -345,7 +345,9 @@ $this->artisan('add', [ 'title' => 'Similar Entry', '--content' => 'Very similar content', - ])->assertFailed() + ]) + ->expectsConfirmation("Supersede existing entry 'similar-id-456' with this new entry?", 'no') + ->assertFailed() ->expectsOutputToContain('duplicate detected'); }); diff --git a/tests/Feature/Commands/KnowledgeShowCommandTest.php b/tests/Feature/Commands/KnowledgeShowCommandTest.php index 513a098..459b271 100644 --- a/tests/Feature/Commands/KnowledgeShowCommandTest.php +++ b/tests/Feature/Commands/KnowledgeShowCommandTest.php @@ -7,6 +7,11 @@ beforeEach(function (): void { $this->qdrantMock = Mockery::mock(QdrantService::class); $this->app->instance(QdrantService::class, $this->qdrantMock); + + // Default: no supersession history (overridden in specific tests) + $this->qdrantMock->shouldReceive('getSupersessionHistory') + ->andReturn(['supersedes' => [], 'superseded_by' => null]) + ->byDefault(); }); it('shows full details of an entry', function (): void { @@ -193,3 +198,142 @@ it('shows repo details if present', function (): void { expect(true)->toBeTrue(); })->skip('repo fields not implemented in Qdrant storage'); + +it('shows superseded indicator for superseded entries', function (): void { + $entry = [ + 'id' => '10', + 'title' => 'Old Entry', + 'content' => 'Old content', + 'category' => 'architecture', + 'tags' => [], + 'module' => null, + 'priority' => 'medium', + 'confidence' => 50, + 'status' => 'draft', + 'usage_count' => 0, + 'created_at' => '2024-01-01T00:00:00+00:00', + 'updated_at' => '2024-01-01T00:00:00+00:00', + 'superseded_by' => 'new-uuid', + 'superseded_date' => '2026-01-15T00:00:00Z', + 'superseded_reason' => 'Updated with newer knowledge', + ]; + + $this->qdrantMock->shouldReceive('getById') + ->once() + ->with('10') + ->andReturn($entry); + + $this->qdrantMock->shouldReceive('incrementUsage') + ->once() + ->with('10') + ->andReturn(true); + + $this->qdrantMock->shouldReceive('getSupersessionHistory') + ->once() + ->with('10') + ->andReturn([ + 'supersedes' => [], + 'superseded_by' => [ + 'id' => 'new-uuid', + 'title' => 'New Entry', + 'content' => 'New content', + ], + ]); + + $this->artisan('show', ['id' => '10']) + ->assertSuccessful() + ->expectsOutputToContain('SUPERSEDED') + ->expectsOutputToContain('new-uuid'); +}); + +it('shows supersession history when entry supersedes others', function (): void { + $entry = [ + 'id' => '11', + 'title' => 'New Entry', + 'content' => 'New content', + 'category' => 'architecture', + 'tags' => [], + 'module' => null, + 'priority' => 'high', + 'confidence' => 90, + 'status' => 'validated', + 'usage_count' => 3, + 'created_at' => '2024-02-01T00:00:00+00:00', + 'updated_at' => '2024-02-01T00:00:00+00:00', + 'superseded_by' => null, + 'superseded_date' => null, + 'superseded_reason' => null, + ]; + + $this->qdrantMock->shouldReceive('getById') + ->once() + ->with('11') + ->andReturn($entry); + + $this->qdrantMock->shouldReceive('incrementUsage') + ->once() + ->with('11') + ->andReturn(true); + + $this->qdrantMock->shouldReceive('getSupersessionHistory') + ->once() + ->with('11') + ->andReturn([ + 'supersedes' => [ + [ + 'id' => 'old-uuid', + 'title' => 'Old Entry', + 'content' => 'Old content', + 'superseded_reason' => 'Updated with newer knowledge', + ], + ], + 'superseded_by' => null, + ]); + + $this->artisan('show', ['id' => '11']) + ->assertSuccessful() + ->expectsOutputToContain('Supersession History') + ->expectsOutputToContain('This entry supersedes') + ->expectsOutputToContain('old-uuid'); +}); + +it('does not show supersession history when none exists', function (): void { + $entry = [ + 'id' => '12', + 'title' => 'Standalone Entry', + 'content' => 'Content', + 'category' => 'architecture', + 'tags' => [], + 'module' => null, + 'priority' => 'medium', + 'confidence' => 50, + 'status' => 'draft', + 'usage_count' => 0, + 'created_at' => '2024-01-01T00:00:00+00:00', + 'updated_at' => '2024-01-01T00:00:00+00:00', + 'superseded_by' => null, + 'superseded_date' => null, + 'superseded_reason' => null, + ]; + + $this->qdrantMock->shouldReceive('getById') + ->once() + ->with('12') + ->andReturn($entry); + + $this->qdrantMock->shouldReceive('incrementUsage') + ->once() + ->with('12') + ->andReturn(true); + + $this->qdrantMock->shouldReceive('getSupersessionHistory') + ->once() + ->with('12') + ->andReturn([ + 'supersedes' => [], + 'superseded_by' => null, + ]); + + $this->artisan('show', ['id' => '12']) + ->assertSuccessful(); +}); diff --git a/tests/Feature/KnowledgeAddCommandTest.php b/tests/Feature/KnowledgeAddCommandTest.php index 338b653..e5ef7e7 100644 --- a/tests/Feature/KnowledgeAddCommandTest.php +++ b/tests/Feature/KnowledgeAddCommandTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Exceptions\Qdrant\DuplicateEntryException; use App\Services\GitContextService; use App\Services\QdrantService; use App\Services\WriteGateService; @@ -270,3 +271,103 @@ ])->assertSuccessful(); }); }); + +it('fails on exact hash duplicate', function (): void { + $this->gitService->shouldReceive('isGitRepository')->andReturn(false); + + $this->qdrantService->shouldReceive('upsert') + ->once() + ->andThrow(DuplicateEntryException::hashMatch('existing-id', 'hash123')); + + $this->artisan('add', [ + 'title' => 'Duplicate Entry', + '--content' => 'Duplicate content', + ])->assertFailed(); +}); + +it('prompts to supersede when similarity duplicate detected and user confirms', function (): void { + $this->gitService->shouldReceive('isGitRepository')->andReturn(false); + + $this->qdrantService->shouldReceive('upsert') + ->once() + ->with(Mockery::any(), Mockery::any(), true) + ->andThrow(DuplicateEntryException::similarityMatch('existing-id', 0.97)); + + // User confirms supersession + $this->qdrantService->shouldReceive('upsert') + ->once() + ->with(Mockery::any(), 'default', false) + ->andReturn(true); + + $this->qdrantService->shouldReceive('markSuperseded') + ->once() + ->with('existing-id', Mockery::type('string'), Mockery::type('string')) + ->andReturn(true); + + $this->artisan('add', [ + 'title' => 'Updated Entry', + '--content' => 'Updated content', + '--confidence' => 80, + ]) + ->expectsConfirmation("Supersede existing entry 'existing-id' with this new entry?", 'yes') + ->assertSuccessful(); +}); + +it('aborts when user declines supersession', function (): void { + $this->gitService->shouldReceive('isGitRepository')->andReturn(false); + + $this->qdrantService->shouldReceive('upsert') + ->once() + ->with(Mockery::any(), Mockery::any(), true) + ->andThrow(DuplicateEntryException::similarityMatch('existing-id', 0.96)); + + $this->artisan('add', [ + 'title' => 'Updated Entry', + '--content' => 'Updated content', + '--confidence' => 80, + ]) + ->expectsConfirmation("Supersede existing entry 'existing-id' with this new entry?", 'no') + ->assertFailed(); +}); + +it('warns about low confidence when superseding', function (): void { + $this->gitService->shouldReceive('isGitRepository')->andReturn(false); + + $this->qdrantService->shouldReceive('upsert') + ->once() + ->with(Mockery::any(), Mockery::any(), true) + ->andThrow(DuplicateEntryException::similarityMatch('existing-id', 0.96)); + + $this->qdrantService->shouldReceive('upsert') + ->once() + ->with(Mockery::any(), 'default', false) + ->andReturn(true); + + $this->qdrantService->shouldReceive('markSuperseded') + ->once() + ->andReturn(true); + + $this->artisan('add', [ + 'title' => 'Low Confidence Entry', + '--content' => 'Content', + '--confidence' => 30, + ]) + ->expectsConfirmation("Supersede existing entry 'existing-id' with this new entry?", 'yes') + ->expectsOutputToContain('Knowledge entry created') + ->assertSuccessful(); +}); + +it('skips duplicate detection with --force flag', function (): void { + $this->gitService->shouldReceive('isGitRepository')->andReturn(false); + + $this->qdrantService->shouldReceive('upsert') + ->once() + ->with(Mockery::any(), Mockery::any(), false) + ->andReturn(true); + + $this->artisan('add', [ + 'title' => 'Forced Entry', + '--content' => 'Content', + '--force' => true, + ])->assertSuccessful(); +}); diff --git a/tests/Feature/KnowledgeSearchCommandTest.php b/tests/Feature/KnowledgeSearchCommandTest.php index 47558b7..78cf66b 100644 --- a/tests/Feature/KnowledgeSearchCommandTest.php +++ b/tests/Feature/KnowledgeSearchCommandTest.php @@ -304,6 +304,7 @@ 'status' => 'draft', 'confidence' => 80, 'score' => 0.92, + 'superseded_by' => null, ], ])); @@ -311,4 +312,74 @@ ->assertSuccessful() ->expectsOutputToContain('score: 0.92'); }); + + it('passes include_superseded filter when flag is set', function (): void { + $this->qdrantService->shouldReceive('search') + ->once() + ->with('test', Mockery::on(fn ($filters): bool => isset($filters['include_superseded']) && $filters['include_superseded'] === true), 20) + ->andReturn(collect([ + [ + 'id' => 'uuid-1', + 'title' => 'Old Entry', + 'content' => 'Old content', + 'tags' => [], + 'category' => null, + 'module' => null, + 'priority' => 'medium', + 'status' => 'draft', + 'confidence' => 50, + 'score' => 0.85, + 'superseded_by' => 'uuid-2', + 'superseded_date' => '2026-01-15T00:00:00Z', + 'superseded_reason' => 'Updated', + ], + ])); + + $this->artisan('search', [ + 'query' => 'test', + '--include-superseded' => true, + ]) + ->assertSuccessful() + ->expectsOutputToContain('Old Entry'); + }); + + it('does not pass include_superseded by default', function (): void { + $this->qdrantService->shouldReceive('search') + ->once() + ->with('test', [], 20) + ->andReturn(collect([])); + + $this->artisan('search', ['query' => 'test']) + ->assertSuccessful(); + }); + + it('shows superseded indicator on superseded entries', function (): void { + $this->qdrantService->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'uuid-1', + 'title' => 'Superseded Entry', + 'content' => 'Old content', + 'tags' => [], + 'category' => null, + 'module' => null, + 'priority' => 'medium', + 'status' => 'draft', + 'confidence' => 50, + 'score' => 0.85, + 'superseded_by' => 'uuid-2', + 'superseded_date' => '2026-01-15T00:00:00Z', + 'superseded_reason' => 'Updated', + ], + ])); + + $this->artisan('search', [ + 'query' => 'test', + '--include-superseded' => true, + ]) + ->assertSuccessful() + ->expectsOutputToContain('SUPERSEDED') + ->expectsOutputToContain('Superseded by: uuid-2'); + }); }); diff --git a/tests/Unit/Services/QdrantServiceTest.php b/tests/Unit/Services/QdrantServiceTest.php index 6db675d..30e2d9c 100644 --- a/tests/Unit/Services/QdrantServiceTest.php +++ b/tests/Unit/Services/QdrantServiceTest.php @@ -3,11 +3,13 @@ declare(strict_types=1); use App\Contracts\EmbeddingServiceInterface; +use App\Exceptions\Qdrant\DuplicateEntryException; use App\Integrations\Qdrant\QdrantConnector; use App\Integrations\Qdrant\Requests\CreateCollection; use App\Integrations\Qdrant\Requests\DeletePoints; use App\Integrations\Qdrant\Requests\GetCollectionInfo; use App\Integrations\Qdrant\Requests\GetPoints; +use App\Integrations\Qdrant\Requests\ScrollPoints; use App\Integrations\Qdrant\Requests\SearchPoints; use App\Integrations\Qdrant\Requests\UpsertPoints; use App\Services\QdrantService; @@ -167,7 +169,7 @@ function mockCollectionExists(Mockery\MockInterface $connector, int $times = 1): 'usage_count' => 5, ]; - expect($this->service->upsert($entry, 'default'))->toBeTrue(); + expect($this->service->upsert($entry, 'default', false))->toBeTrue(); }); it('successfully upserts an entry with minimal fields', function (): void { @@ -190,7 +192,7 @@ function mockCollectionExists(Mockery\MockInterface $connector, int $times = 1): 'content' => 'Minimal content', ]; - expect($this->service->upsert($entry, 'default'))->toBeTrue(); + expect($this->service->upsert($entry, 'default', false))->toBeTrue(); }); it('throws exception when embedding generation fails', function (): void { @@ -207,7 +209,7 @@ function mockCollectionExists(Mockery\MockInterface $connector, int $times = 1): 'content' => 'Test content', ]; - expect(fn () => $this->service->upsert($entry, 'default')) + expect(fn () => $this->service->upsert($entry, 'default', false)) ->toThrow(RuntimeException::class, 'Failed to generate embedding'); }); @@ -231,7 +233,7 @@ function mockCollectionExists(Mockery\MockInterface $connector, int $times = 1): 'content' => 'Test content', ]; - expect(fn () => $this->service->upsert($entry, 'default')) + expect(fn () => $this->service->upsert($entry, 'default', false)) ->toThrow(RuntimeException::class, 'Failed to upsert entry to Qdrant: {"error":"Upsert failed"}'); }); @@ -258,10 +260,10 @@ function mockCollectionExists(Mockery\MockInterface $connector, int $times = 1): ]; // First call - generates embedding - $this->service->upsert($entry, 'default'); + $this->service->upsert($entry, 'default', false); // Second call - uses cached embedding - $this->service->upsert($entry, 'default'); + $this->service->upsert($entry, 'default', false); }); it('does not cache embeddings when caching is disabled', function (): void { @@ -287,10 +289,10 @@ function mockCollectionExists(Mockery\MockInterface $connector, int $times = 1): ]; // First call - generates embedding - $this->service->upsert($entry, 'default'); + $this->service->upsert($entry, 'default', false); // Second call - generates embedding again (not cached) - $this->service->upsert($entry, 'default'); + $this->service->upsert($entry, 'default', false); }); }); @@ -1017,3 +1019,497 @@ function mockCollectionExists(Mockery\MockInterface $connector, int $times = 1): expect($result)->toBeTrue(); }); }); + +describe('upsert duplicate detection', function (): void { + it('throws hash match exception when exact duplicate content exists', function (): void { + $this->mockEmbedding->shouldReceive('generate') + ->with('Test Title Test content') + ->once() + ->andReturn([0.1, 0.2, 0.3]); + + mockCollectionExists($this->mockConnector, 2); + + // Mock findSimilar search returning exact match + $searchResponse = createMockResponse(true, 200, [ + 'result' => [ + [ + 'id' => 'existing-id', + 'score' => 0.99, + 'payload' => [ + 'title' => 'Test Title', + 'content' => 'Test content', + ], + ], + ], + ]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(SearchPoints::class)) + ->once() + ->andReturn($searchResponse); + + $entry = [ + 'id' => 'new-id', + 'title' => 'Test Title', + 'content' => 'Test content', + ]; + + expect(fn () => $this->service->upsert($entry, 'default', true)) + ->toThrow(DuplicateEntryException::class); + }); + + it('throws similarity match exception when similar entry exists', function (): void { + $this->mockEmbedding->shouldReceive('generate') + ->with('Test Title Test content here') + ->once() + ->andReturn([0.1, 0.2, 0.3]); + + mockCollectionExists($this->mockConnector, 2); + + // Mock findSimilar search returning similar (not exact) match + $searchResponse = createMockResponse(true, 200, [ + 'result' => [ + [ + 'id' => 'existing-id', + 'score' => 0.97, + 'payload' => [ + 'title' => 'Test Title', + 'content' => 'Different content', + ], + ], + ], + ]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(SearchPoints::class)) + ->once() + ->andReturn($searchResponse); + + $entry = [ + 'id' => 'new-id', + 'title' => 'Test Title', + 'content' => 'Test content here', + ]; + + expect(fn () => $this->service->upsert($entry, 'default', true)) + ->toThrow(DuplicateEntryException::class); + }); + + it('skips duplicate detection when checkDuplicates is false', function (): void { + $this->mockEmbedding->shouldReceive('generate') + ->with('Test Title Test content') + ->once() + ->andReturn([0.1, 0.2, 0.3]); + + mockCollectionExists($this->mockConnector); + + $upsertResponse = createMockResponse(true); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(UpsertPoints::class)) + ->once() + ->andReturn($upsertResponse); + + $entry = [ + 'id' => 'new-id', + 'title' => 'Test Title', + 'content' => 'Test content', + ]; + + // Should NOT make a search call for duplicate detection + $this->mockConnector->shouldNotReceive('send') + ->with(Mockery::type(SearchPoints::class)); + + expect($this->service->upsert($entry, 'default', false))->toBeTrue(); + }); + + it('proceeds when no similar entries found', function (): void { + $this->mockEmbedding->shouldReceive('generate') + ->with('Unique Title Unique content') + ->once() + ->andReturn([0.1, 0.2, 0.3]); + + mockCollectionExists($this->mockConnector, 2); + + // Mock findSimilar returning no results + $searchResponse = createMockResponse(true, 200, ['result' => []]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(SearchPoints::class)) + ->once() + ->andReturn($searchResponse); + + $upsertResponse = createMockResponse(true); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(UpsertPoints::class)) + ->once() + ->andReturn($upsertResponse); + + $entry = [ + 'id' => 'new-id', + 'title' => 'Unique Title', + 'content' => 'Unique content', + ]; + + expect($this->service->upsert($entry, 'default', true))->toBeTrue(); + }); + + it('stores superseded fields in payload', function (): void { + $this->mockEmbedding->shouldReceive('generate') + ->with('Test Title Test content') + ->once() + ->andReturn([0.1, 0.2, 0.3]); + + mockCollectionExists($this->mockConnector); + + $upsertResponse = createMockResponse(true); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(UpsertPoints::class)) + ->once() + ->andReturn($upsertResponse); + + $entry = [ + 'id' => 'test-id', + 'title' => 'Test Title', + 'content' => 'Test content', + 'superseded_by' => 'new-id', + 'superseded_date' => '2026-01-01T00:00:00Z', + 'superseded_reason' => 'Updated knowledge', + ]; + + expect($this->service->upsert($entry, 'default', false))->toBeTrue(); + }); +}); + +describe('findSimilar', function (): void { + it('returns similar entries above threshold', function (): void { + mockCollectionExists($this->mockConnector); + + $searchResponse = createMockResponse(true, 200, [ + 'result' => [ + [ + 'id' => 'similar-1', + 'score' => 0.97, + 'payload' => [ + 'title' => 'Similar Entry', + 'content' => 'Similar content', + ], + ], + ], + ]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(SearchPoints::class)) + ->once() + ->andReturn($searchResponse); + + $results = $this->service->findSimilar([0.1, 0.2, 0.3], 'default', 0.95); + + expect($results)->toHaveCount(1); + expect($results->first()['id'])->toBe('similar-1'); + expect($results->first()['score'])->toBe(0.97); + }); + + it('returns empty collection when no similar entries found', function (): void { + mockCollectionExists($this->mockConnector); + + $searchResponse = createMockResponse(true, 200, ['result' => []]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(SearchPoints::class)) + ->once() + ->andReturn($searchResponse); + + $results = $this->service->findSimilar([0.1, 0.2, 0.3]); + + expect($results)->toBeEmpty(); + }); + + it('returns empty collection when search fails', function (): void { + mockCollectionExists($this->mockConnector); + + $searchResponse = createMockResponse(false, 500); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(SearchPoints::class)) + ->once() + ->andReturn($searchResponse); + + $results = $this->service->findSimilar([0.1, 0.2, 0.3]); + + expect($results)->toBeEmpty(); + }); +}); + +describe('markSuperseded', function (): void { + it('marks an existing entry as superseded', function (): void { + mockCollectionExists($this->mockConnector, 2); + + // Mock getById for updateFields + $getPointsResponse = createMockResponse(true, 200, [ + 'result' => [ + [ + 'id' => 'old-id', + 'payload' => [ + 'title' => 'Old Entry', + 'content' => 'Old content', + 'tags' => [], + 'category' => null, + 'module' => null, + 'priority' => 'medium', + 'status' => 'draft', + 'confidence' => 50, + 'usage_count' => 0, + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-01T00:00:00Z', + ], + ], + ], + ]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(GetPoints::class)) + ->once() + ->andReturn($getPointsResponse); + + $this->mockEmbedding->shouldReceive('generate') + ->once() + ->andReturn([0.1, 0.2, 0.3]); + + $upsertResponse = createMockResponse(true); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(UpsertPoints::class)) + ->once() + ->andReturn($upsertResponse); + + $result = $this->service->markSuperseded('old-id', 'new-id', 'Newer knowledge available'); + + expect($result)->toBeTrue(); + }); + + it('returns false when entry does not exist', function (): void { + mockCollectionExists($this->mockConnector); + + $getPointsResponse = createMockResponse(true, 200, ['result' => []]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(GetPoints::class)) + ->once() + ->andReturn($getPointsResponse); + + $result = $this->service->markSuperseded('nonexistent', 'new-id'); + + expect($result)->toBeFalse(); + }); +}); + +describe('getSupersessionHistory', function (): void { + it('returns empty history when entry does not exist', function (): void { + mockCollectionExists($this->mockConnector); + + $getPointsResponse = createMockResponse(true, 200, ['result' => []]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(GetPoints::class)) + ->once() + ->andReturn($getPointsResponse); + + $history = $this->service->getSupersessionHistory('nonexistent'); + + expect($history['supersedes'])->toBeEmpty(); + expect($history['superseded_by'])->toBeNull(); + }); + + it('returns successor when entry is superseded', function (): void { + // Mock getById for the entry itself + mockCollectionExists($this->mockConnector, 3); + + $entryResponse = createMockResponse(true, 200, [ + 'result' => [ + [ + 'id' => 'old-id', + 'payload' => [ + 'title' => 'Old Entry', + 'content' => 'Old content', + 'superseded_by' => 'new-id', + 'superseded_date' => '2026-01-15T00:00:00Z', + 'superseded_reason' => 'Updated', + ], + ], + ], + ]); + + // Mock getById for the successor + $successorResponse = createMockResponse(true, 200, [ + 'result' => [ + [ + 'id' => 'new-id', + 'payload' => [ + 'title' => 'New Entry', + 'content' => 'New content', + ], + ], + ], + ]); + + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(GetPoints::class)) + ->twice() + ->andReturn($entryResponse, $successorResponse); + + // Mock scroll for predecessors + $scrollResponse = createMockResponse(true, 200, [ + 'result' => ['points' => []], + ]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(ScrollPoints::class)) + ->once() + ->andReturn($scrollResponse); + + $history = $this->service->getSupersessionHistory('old-id'); + + expect($history['superseded_by'])->not()->toBeNull(); + expect($history['superseded_by']['id'])->toBe('new-id'); + expect($history['superseded_by']['title'])->toBe('New Entry'); + }); + + it('returns predecessors when entry supersedes others', function (): void { + mockCollectionExists($this->mockConnector, 2); + + // Mock getById for the entry itself (not superseded) + $entryResponse = createMockResponse(true, 200, [ + 'result' => [ + [ + 'id' => 'new-id', + 'payload' => [ + 'title' => 'New Entry', + 'content' => 'New content', + 'superseded_by' => null, + ], + ], + ], + ]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(GetPoints::class)) + ->once() + ->andReturn($entryResponse); + + // Mock scroll for predecessors + $scrollResponse = createMockResponse(true, 200, [ + 'result' => [ + 'points' => [ + [ + 'id' => 'old-id-1', + 'payload' => [ + 'title' => 'Old Entry 1', + 'content' => 'Old content 1', + 'superseded_by' => 'new-id', + 'superseded_reason' => 'Updated by new entry', + ], + ], + [ + 'id' => 'old-id-2', + 'payload' => [ + 'title' => 'Old Entry 2', + 'content' => 'Old content 2', + 'superseded_by' => 'new-id', + 'superseded_reason' => 'Also updated', + ], + ], + ], + ], + ]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(ScrollPoints::class)) + ->once() + ->andReturn($scrollResponse); + + $history = $this->service->getSupersessionHistory('new-id'); + + expect($history['superseded_by'])->toBeNull(); + expect($history['supersedes'])->toHaveCount(2); + expect($history['supersedes'][0]['id'])->toBe('old-id-1'); + expect($history['supersedes'][1]['id'])->toBe('old-id-2'); + }); + + it('returns empty predecessors when scroll fails', function (): void { + mockCollectionExists($this->mockConnector, 2); + + $entryResponse = createMockResponse(true, 200, [ + 'result' => [ + [ + 'id' => 'test-id', + 'payload' => [ + 'title' => 'Entry', + 'content' => 'Content', + 'superseded_by' => null, + ], + ], + ], + ]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(GetPoints::class)) + ->once() + ->andReturn($entryResponse); + + $scrollResponse = createMockResponse(false, 500); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(ScrollPoints::class)) + ->once() + ->andReturn($scrollResponse); + + $history = $this->service->getSupersessionHistory('test-id'); + + expect($history['supersedes'])->toBeEmpty(); + expect($history['superseded_by'])->toBeNull(); + }); +}); + +describe('getById with superseded fields', function (): void { + it('includes superseded fields in response', function (): void { + mockCollectionExists($this->mockConnector); + + $getPointsResponse = createMockResponse(true, 200, [ + 'result' => [ + [ + 'id' => 'test-id', + 'payload' => [ + 'title' => 'Test Entry', + 'content' => 'Content', + 'superseded_by' => 'new-id', + 'superseded_date' => '2026-01-15T00:00:00Z', + 'superseded_reason' => 'Replaced', + ], + ], + ], + ]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(GetPoints::class)) + ->once() + ->andReturn($getPointsResponse); + + $result = $this->service->getById('test-id'); + + expect($result)->not()->toBeNull(); + expect($result['superseded_by'])->toBe('new-id'); + expect($result['superseded_date'])->toBe('2026-01-15T00:00:00Z'); + expect($result['superseded_reason'])->toBe('Replaced'); + }); + + it('returns null superseded fields when not set', function (): void { + mockCollectionExists($this->mockConnector); + + $getPointsResponse = createMockResponse(true, 200, [ + 'result' => [ + [ + 'id' => 'test-id', + 'payload' => [ + 'title' => 'Test Entry', + 'content' => 'Content', + ], + ], + ], + ]); + $this->mockConnector->shouldReceive('send') + ->with(Mockery::type(GetPoints::class)) + ->once() + ->andReturn($getPointsResponse); + + $result = $this->service->getById('test-id'); + + expect($result)->not()->toBeNull(); + expect($result['superseded_by'])->toBeNull(); + expect($result['superseded_date'])->toBeNull(); + expect($result['superseded_reason'])->toBeNull(); + }); +});