diff --git a/app/Commands/CorrectCommand.php b/app/Commands/CorrectCommand.php new file mode 100644 index 0000000..ade860d --- /dev/null +++ b/app/Commands/CorrectCommand.php @@ -0,0 +1,95 @@ +argument('id'); + if (! is_string($idArg) || $idArg === '') { + error('Invalid or missing ID argument.'); + + return self::FAILURE; + } + $id = $idArg; + + /** @var string|null $newValue */ + $newValue = is_string($this->option('new-value')) ? $this->option('new-value') : null; + if ($newValue === null || $newValue === '') { + error('The --new-value option is required.'); + + return self::FAILURE; + } + + // Verify entry exists + $entry = spin( + fn (): ?array => $qdrant->getById($id), + 'Fetching entry...' + ); + + if ($entry === null) { + error("Entry not found: {$id}"); + + return self::FAILURE; + } + + $this->info("Correcting entry: {$entry['title']}"); + + // Execute correction with propagation + /** @var array{corrected_entry_id: string, superseded_ids: array, conflicts_found: int, log_entry_id: string} $result */ + $result = spin( + fn (): array => $correction->correct($id, $newValue), + 'Applying correction and propagating changes...' + ); + + // Display propagation report + $this->displayReport($entry, $newValue, $result); + + return self::SUCCESS; + } + + /** + * Display the propagation report. + * + * @param array $originalEntry + * @param array{corrected_entry_id: string, superseded_ids: array, conflicts_found: int, log_entry_id: string} $result + */ + private function displayReport(array $originalEntry, string $newValue, array $result): void + { + $this->info('Correction applied successfully!'); + $this->newLine(); + + $this->line("Original Entry: {$originalEntry['id']}"); + $this->line("Original Title: {$originalEntry['title']}"); + $this->line("New Entry ID: {$result['corrected_entry_id']}"); + $this->line('Evidence: user correction'); + $this->line("Conflicts Found: {$result['conflicts_found']}"); + $this->line('Entries Superseded: '.count($result['superseded_ids'])); + $this->line("Log Entry: {$result['log_entry_id']}"); + + if (count($result['superseded_ids']) > 0) { + $this->newLine(); + $supersededText = 'Superseded entries: '.implode(', ', $result['superseded_ids']); + $this->line($supersededText); + } + + $this->newLine(); + $this->comment("View corrected entry: ./know show {$result['corrected_entry_id']}"); + } +} diff --git a/app/Services/CorrectionService.php b/app/Services/CorrectionService.php new file mode 100644 index 0000000..565e0d6 --- /dev/null +++ b/app/Services/CorrectionService.php @@ -0,0 +1,196 @@ +, conflicts_found: int, log_entry_id: string} + */ + public function correct(string|int $id, string $newValue): array + { + // 1. Fetch the original entry + $original = $this->qdrant->getById($id); + if ($original === null) { + throw new \RuntimeException("Entry not found: {$id}"); + } + + // 2. Search for conflicting entries using the original content + $conflicts = $this->findConflicts($original, $id); + + // 3. Supersede conflicting entries + $supersededIds = $this->supersedConflicts($conflicts, $id); + + // 4. Supersede the original entry itself + $this->qdrant->updateFields($id, [ + 'status' => 'deprecated', + 'confidence' => self::SUPERSEDED_CONFIDENCE, + 'tags' => $this->appendTag($original['tags'] ?? [], 'superseded'), + ]); + + // 5. Create corrected entry + $correctedId = $this->createCorrectedEntry($original, $newValue); + + // 6. Log correction to daily log + $logId = $this->logCorrection($original, $newValue, $correctedId, $supersededIds); + + return [ + 'corrected_entry_id' => $correctedId, + 'superseded_ids' => $supersededIds, + 'conflicts_found' => count($conflicts), + 'log_entry_id' => $logId, + ]; + } + + /** + * Find entries that conflict with the original entry's content. + * + * @param array $original + * @return array> + */ + public function findConflicts(array $original, string|int $excludeId): array + { + $searchText = $original['title'].' '.$original['content']; + + $results = $this->qdrant->search($searchText, [], 20); + + return $results->filter(function (array $entry) use ($excludeId): bool { + // Exclude the original entry itself + if ((string) $entry['id'] === (string) $excludeId) { + return false; + } + + // Only consider entries that are not already deprecated + if (($entry['status'] ?? '') === 'deprecated') { + return false; + } + + // Must meet similarity threshold + return $entry['score'] >= self::CONFLICT_SIMILARITY_THRESHOLD; + })->values()->toArray(); + } + + /** + * Mark conflicting entries as superseded. + * + * @param array> $conflicts + * @return array + */ + public function supersedConflicts(array $conflicts, string|int $correctedFromId): array + { + $supersededIds = []; + + foreach ($conflicts as $conflict) { + $conflictId = $conflict['id']; + $existingTags = $conflict['tags'] ?? []; + + $this->qdrant->updateFields($conflictId, [ + 'status' => 'deprecated', + 'confidence' => self::SUPERSEDED_CONFIDENCE, + 'tags' => $this->appendTag( + is_array($existingTags) ? $existingTags : [], + 'superseded' + ), + ]); + + $supersededIds[] = $conflictId; + } + + return $supersededIds; + } + + /** + * Create a new corrected entry based on the original. + * + * @param array $original + */ + private function createCorrectedEntry(array $original, string $newValue): string + { + $correctedId = Str::uuid()->toString(); + + $this->qdrant->upsert([ + 'id' => $correctedId, + 'title' => $original['title'], + 'content' => $newValue, + 'category' => $original['category'] ?? null, + 'module' => $original['module'] ?? null, + 'priority' => $original['priority'] ?? 'medium', + 'confidence' => 90, + 'status' => 'validated', + 'tags' => $this->appendTag($original['tags'] ?? [], 'corrected'), + 'evidence' => 'user correction', + 'last_verified' => now()->toIso8601String(), + ], 'default', true); + + return $correctedId; + } + + /** + * Log the correction to the daily log. + * + * @param array $original + * @param array $supersededIds + */ + private function logCorrection(array $original, string $newValue, string $correctedId, array $supersededIds): string + { + $logId = Str::uuid()->toString(); + $today = now()->format('Y-m-d'); + + $supersededList = count($supersededIds) > 0 + ? implode(', ', $supersededIds) + : 'none'; + + $content = "**Correction Log - {$today}**\n\n" + ."- **Original Entry**: {$original['id']}\n" + ."- **Original Title**: {$original['title']}\n" + ."- **Corrected Entry**: {$correctedId}\n" + ."- **New Value**: {$newValue}\n" + ."- **Superseded Entries**: {$supersededList}\n" + ."- **Evidence**: user correction\n" + .'- **Timestamp**: '.now()->toIso8601String(); + + $this->qdrant->upsert([ + 'id' => $logId, + 'title' => "Correction Log - {$original['title']} - {$today}", + 'content' => $content, + 'category' => $original['category'] ?? null, + 'tags' => ['correction-log', $today, 'correction'], + 'priority' => 'medium', + 'confidence' => 100, + 'status' => 'validated', + 'evidence' => 'user correction', + 'last_verified' => now()->toIso8601String(), + ], 'default', true); + + return $logId; + } + + /** + * Append a tag to an existing tags array without duplicates. + * + * @param array $tags + * @return array + */ + private function appendTag(array $tags, string $tag): array + { + if (! in_array($tag, $tags, true)) { + $tags[] = $tag; + } + + return array_values($tags); + } +} diff --git a/tests/Feature/CorrectCommandTest.php b/tests/Feature/CorrectCommandTest.php new file mode 100644 index 0000000..df94b16 --- /dev/null +++ b/tests/Feature/CorrectCommandTest.php @@ -0,0 +1,143 @@ +qdrant = mock(QdrantService::class); + $this->correction = mock(CorrectionService::class); + app()->instance(QdrantService::class, $this->qdrant); + app()->instance(CorrectionService::class, $this->correction); + }); + + it('fails when entry not found', function (): void { + $this->qdrant->shouldReceive('getById') + ->once() + ->with('nonexistent-id') + ->andReturn(null); + + $this->artisan('correct', [ + 'id' => 'nonexistent-id', + '--new-value' => 'corrected content', + ]) + ->expectsOutputToContain('Entry not found: nonexistent-id') + ->assertFailed(); + }); + + it('fails when new-value option is missing', function (): void { + $this->artisan('correct', ['id' => 'some-id']) + ->expectsOutputToContain('The --new-value option is required.') + ->assertFailed(); + }); + + it('corrects an entry with no conflicts', function (): void { + $entry = [ + 'id' => 'entry-1', + 'title' => 'Original Title', + 'content' => 'Original content', + 'category' => 'debugging', + 'status' => 'validated', + 'confidence' => 80, + 'tags' => ['php'], + ]; + + $this->qdrant->shouldReceive('getById') + ->once() + ->with('entry-1') + ->andReturn($entry); + + $this->correction->shouldReceive('correct') + ->once() + ->with('entry-1', 'corrected content') + ->andReturn([ + 'corrected_entry_id' => 'new-entry-uuid', + 'superseded_ids' => [], + 'conflicts_found' => 0, + 'log_entry_id' => 'log-uuid', + ]); + + $this->artisan('correct', [ + 'id' => 'entry-1', + '--new-value' => 'corrected content', + ]) + ->expectsOutputToContain('Correction applied successfully!') + ->expectsOutputToContain('entry-1') + ->expectsOutputToContain('Original Title') + ->expectsOutputToContain('new-entry-uuid') + ->expectsOutputToContain('user correction') + ->expectsOutputToContain('0') + ->assertSuccessful(); + }); + + it('corrects an entry with conflicts and shows superseded entries', function (): void { + $entry = [ + 'id' => 'entry-1', + 'title' => 'PHP Version Info', + 'content' => 'PHP minimum version is 7.4', + 'category' => 'architecture', + 'status' => 'validated', + 'confidence' => 90, + 'tags' => ['php', 'version'], + ]; + + $this->qdrant->shouldReceive('getById') + ->once() + ->with('entry-1') + ->andReturn($entry); + + $this->correction->shouldReceive('correct') + ->once() + ->with('entry-1', 'PHP minimum version is 8.2') + ->andReturn([ + 'corrected_entry_id' => 'corrected-uuid', + 'superseded_ids' => ['conflict-1', 'conflict-2'], + 'conflicts_found' => 2, + 'log_entry_id' => 'log-uuid', + ]); + + $this->artisan('correct', [ + 'id' => 'entry-1', + '--new-value' => 'PHP minimum version is 8.2', + ]) + ->expectsOutputToContain('Correction applied successfully!') + ->expectsOutputToContain('Conflicts Found: 2') + ->expectsOutputToContain('Entries Superseded: 2') + ->expectsOutputToContain('conflict-1, conflict-2') + ->assertSuccessful(); + }); + + it('shows the view command hint after correction', function (): void { + $entry = [ + 'id' => 'entry-1', + 'title' => 'Test Entry', + 'content' => 'Test content', + 'status' => 'draft', + 'confidence' => 50, + 'tags' => [], + ]; + + $this->qdrant->shouldReceive('getById') + ->once() + ->with('entry-1') + ->andReturn($entry); + + $this->correction->shouldReceive('correct') + ->once() + ->andReturn([ + 'corrected_entry_id' => 'new-uuid', + 'superseded_ids' => [], + 'conflicts_found' => 0, + 'log_entry_id' => 'log-uuid', + ]); + + $this->artisan('correct', [ + 'id' => 'entry-1', + '--new-value' => 'Updated content', + ]) + ->expectsOutputToContain('View corrected entry: ./know show new-uuid') + ->assertSuccessful(); + }); +}); diff --git a/tests/Unit/CorrectionServiceTest.php b/tests/Unit/CorrectionServiceTest.php new file mode 100644 index 0000000..4a66565 --- /dev/null +++ b/tests/Unit/CorrectionServiceTest.php @@ -0,0 +1,414 @@ +qdrant = mock(QdrantService::class); + $this->service = new CorrectionService($this->qdrant); + }); + + describe('correct', function (): void { + it('throws exception when entry not found', function (): void { + $this->qdrant->shouldReceive('getById') + ->once() + ->with('missing-id') + ->andReturn(null); + + $this->service->correct('missing-id', 'new value'); + })->throws(\RuntimeException::class, 'Entry not found: missing-id'); + + it('corrects an entry with no conflicts', function (): void { + $original = [ + 'id' => 'entry-1', + 'title' => 'Test Entry', + 'content' => 'Old content', + 'category' => 'debugging', + 'module' => null, + 'priority' => 'medium', + 'status' => 'validated', + 'confidence' => 80, + 'tags' => ['php'], + 'evidence' => null, + ]; + + $this->qdrant->shouldReceive('getById') + ->once() + ->with('entry-1') + ->andReturn($original); + + // Search returns no conflicts (only the original itself) + $this->qdrant->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'entry-1', + 'score' => 1.0, + 'title' => 'Test Entry', + 'content' => 'Old content', + 'status' => 'validated', + 'tags' => ['php'], + ], + ])); + + // Supersede original + $this->qdrant->shouldReceive('updateFields') + ->once() + ->with('entry-1', Mockery::on(fn (array $fields): bool => $fields['status'] === 'deprecated' + && $fields['confidence'] === 10 + && in_array('superseded', $fields['tags'], true))) + ->andReturn(true); + + // Create corrected entry + $this->qdrant->shouldReceive('upsert') + ->once() + ->with(Mockery::on(fn (array $entry): bool => $entry['title'] === 'Test Entry' + && $entry['content'] === 'Corrected content' + && $entry['confidence'] === 90 + && $entry['status'] === 'validated' + && $entry['evidence'] === 'user correction' + && in_array('corrected', $entry['tags'], true)), 'default', true) + ->andReturn(true); + + // Create log entry + $this->qdrant->shouldReceive('upsert') + ->once() + ->with(Mockery::on(fn (array $entry): bool => str_contains($entry['title'], 'Correction Log') + && str_contains($entry['content'], 'entry-1') + && str_contains($entry['content'], 'Corrected content') + && in_array('correction-log', $entry['tags'], true) + && $entry['evidence'] === 'user correction'), 'default', true) + ->andReturn(true); + + $result = $this->service->correct('entry-1', 'Corrected content'); + + expect($result)->toHaveKeys(['corrected_entry_id', 'superseded_ids', 'conflicts_found', 'log_entry_id']); + expect($result['superseded_ids'])->toBeEmpty(); + expect($result['conflicts_found'])->toBe(0); + }); + + it('corrects an entry and supersedes conflicts', function (): void { + $original = [ + 'id' => 'entry-1', + 'title' => 'PHP Version', + 'content' => 'PHP 7.4 required', + 'category' => 'architecture', + 'module' => null, + 'priority' => 'high', + 'status' => 'validated', + 'confidence' => 85, + 'tags' => ['php'], + 'evidence' => null, + ]; + + $this->qdrant->shouldReceive('getById') + ->once() + ->with('entry-1') + ->andReturn($original); + + // Search returns conflicts + $this->qdrant->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'entry-1', + 'score' => 1.0, + 'title' => 'PHP Version', + 'content' => 'PHP 7.4 required', + 'status' => 'validated', + 'tags' => ['php'], + ], + [ + 'id' => 'conflict-1', + 'score' => 0.92, + 'title' => 'PHP Requirements', + 'content' => 'Use PHP 7.4', + 'status' => 'validated', + 'tags' => ['php', 'requirements'], + ], + [ + 'id' => 'conflict-2', + 'score' => 0.88, + 'title' => 'Version Policy', + 'content' => 'PHP 7.4 minimum', + 'status' => 'draft', + 'tags' => [], + ], + ])); + + // Supersede conflict-1 + $this->qdrant->shouldReceive('updateFields') + ->once() + ->with('conflict-1', Mockery::on(fn (array $fields): bool => $fields['status'] === 'deprecated' + && $fields['confidence'] === 10 + && in_array('superseded', $fields['tags'], true))) + ->andReturn(true); + + // Supersede conflict-2 + $this->qdrant->shouldReceive('updateFields') + ->once() + ->with('conflict-2', Mockery::on(fn (array $fields): bool => $fields['status'] === 'deprecated' + && $fields['confidence'] === 10 + && in_array('superseded', $fields['tags'], true))) + ->andReturn(true); + + // Supersede original + $this->qdrant->shouldReceive('updateFields') + ->once() + ->with('entry-1', Mockery::on(fn (array $fields): bool => $fields['status'] === 'deprecated' + && $fields['confidence'] === 10)) + ->andReturn(true); + + // Create corrected entry + $this->qdrant->shouldReceive('upsert') + ->once() + ->with(Mockery::on(fn (array $entry): bool => $entry['content'] === 'PHP 8.2 required' + && $entry['evidence'] === 'user correction' + && $entry['status'] === 'validated'), 'default', true) + ->andReturn(true); + + // Create log entry + $this->qdrant->shouldReceive('upsert') + ->once() + ->with(Mockery::on(fn (array $entry): bool => str_contains($entry['content'], 'conflict-1') + && str_contains($entry['content'], 'conflict-2')), 'default', true) + ->andReturn(true); + + $result = $this->service->correct('entry-1', 'PHP 8.2 required'); + + expect($result['superseded_ids'])->toBe(['conflict-1', 'conflict-2']); + expect($result['conflicts_found'])->toBe(2); + }); + + it('skips already deprecated entries when finding conflicts', function (): void { + $original = [ + 'id' => 'entry-1', + 'title' => 'Test', + 'content' => 'Test content', + 'category' => null, + 'module' => null, + 'priority' => 'medium', + 'status' => 'validated', + 'confidence' => 80, + 'tags' => [], + 'evidence' => null, + ]; + + $this->qdrant->shouldReceive('getById') + ->once() + ->with('entry-1') + ->andReturn($original); + + // Search returns a deprecated entry that should be skipped + $this->qdrant->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'entry-1', + 'score' => 1.0, + 'title' => 'Test', + 'content' => 'Test content', + 'status' => 'validated', + 'tags' => [], + ], + [ + 'id' => 'deprecated-entry', + 'score' => 0.90, + 'title' => 'Old Test', + 'content' => 'Old content', + 'status' => 'deprecated', + 'tags' => ['superseded'], + ], + ])); + + // Only supersede original (no conflicts found) + $this->qdrant->shouldReceive('updateFields') + ->once() + ->with('entry-1', Mockery::type('array')) + ->andReturn(true); + + // Create corrected entry + $this->qdrant->shouldReceive('upsert') + ->once() + ->with(Mockery::on(fn (array $entry): bool => $entry['evidence'] === 'user correction'), 'default', true) + ->andReturn(true); + + // Create log entry + $this->qdrant->shouldReceive('upsert') + ->once() + ->with(Mockery::on(fn (array $entry): bool => in_array('correction-log', $entry['tags'], true)), 'default', true) + ->andReturn(true); + + $result = $this->service->correct('entry-1', 'Updated content'); + + expect($result['conflicts_found'])->toBe(0); + expect($result['superseded_ids'])->toBeEmpty(); + }); + + it('skips entries below similarity threshold', function (): void { + $original = [ + 'id' => 'entry-1', + 'title' => 'Specific Topic', + 'content' => 'Very specific content', + 'category' => null, + 'module' => null, + 'priority' => 'medium', + 'status' => 'validated', + 'confidence' => 70, + 'tags' => [], + 'evidence' => null, + ]; + + $this->qdrant->shouldReceive('getById') + ->once() + ->with('entry-1') + ->andReturn($original); + + // Search returns a low-similarity entry + $this->qdrant->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'entry-1', + 'score' => 1.0, + 'title' => 'Specific Topic', + 'content' => 'Very specific content', + 'status' => 'validated', + 'tags' => [], + ], + [ + 'id' => 'low-sim-entry', + 'score' => 0.75, + 'title' => 'Somewhat Related', + 'content' => 'Different content', + 'status' => 'validated', + 'tags' => [], + ], + ])); + + // Only supersede original + $this->qdrant->shouldReceive('updateFields') + ->once() + ->with('entry-1', Mockery::type('array')) + ->andReturn(true); + + // Create corrected entry + $this->qdrant->shouldReceive('upsert') + ->once() + ->with(Mockery::on(fn (array $entry): bool => $entry['evidence'] === 'user correction'), 'default', true) + ->andReturn(true); + + // Create log entry + $this->qdrant->shouldReceive('upsert') + ->once() + ->with(Mockery::on(fn (array $entry): bool => in_array('correction-log', $entry['tags'], true)), 'default', true) + ->andReturn(true); + + $result = $this->service->correct('entry-1', 'Updated content'); + + expect($result['conflicts_found'])->toBe(0); + expect($result['superseded_ids'])->toBeEmpty(); + }); + }); + + describe('findConflicts', function (): void { + it('returns empty array when no conflicts found', function (): void { + $original = [ + 'title' => 'Test', + 'content' => 'Content', + ]; + + $this->qdrant->shouldReceive('search') + ->once() + ->andReturn(collect([])); + + $conflicts = $this->service->findConflicts($original, 'entry-1'); + + expect($conflicts)->toBeEmpty(); + }); + + it('excludes the original entry from conflicts', function (): void { + $original = [ + 'title' => 'Test', + 'content' => 'Content', + ]; + + $this->qdrant->shouldReceive('search') + ->once() + ->andReturn(collect([ + [ + 'id' => 'entry-1', + 'score' => 1.0, + 'title' => 'Test', + 'content' => 'Content', + 'status' => 'validated', + 'tags' => [], + ], + ])); + + $conflicts = $this->service->findConflicts($original, 'entry-1'); + + expect($conflicts)->toBeEmpty(); + }); + }); + + describe('supersedConflicts', function (): void { + it('marks each conflict as superseded', function (): void { + $conflicts = [ + [ + 'id' => 'c1', + 'tags' => ['existing-tag'], + ], + [ + 'id' => 'c2', + 'tags' => [], + ], + ]; + + $this->qdrant->shouldReceive('updateFields') + ->once() + ->with('c1', Mockery::on(fn (array $fields): bool => $fields['status'] === 'deprecated' + && $fields['confidence'] === 10 + && $fields['tags'] === ['existing-tag', 'superseded'])) + ->andReturn(true); + + $this->qdrant->shouldReceive('updateFields') + ->once() + ->with('c2', Mockery::on(fn (array $fields): bool => $fields['status'] === 'deprecated' + && $fields['confidence'] === 10 + && $fields['tags'] === ['superseded'])) + ->andReturn(true); + + $ids = $this->service->supersedConflicts($conflicts, 'entry-1'); + + expect($ids)->toBe(['c1', 'c2']); + }); + + it('returns empty array when no conflicts', function (): void { + $ids = $this->service->supersedConflicts([], 'entry-1'); + + expect($ids)->toBeEmpty(); + }); + + it('does not duplicate superseded tag', function (): void { + $conflicts = [ + [ + 'id' => 'c1', + 'tags' => ['superseded', 'other'], + ], + ]; + + $this->qdrant->shouldReceive('updateFields') + ->once() + ->with('c1', Mockery::on(fn (array $fields): bool => $fields['tags'] === ['superseded', 'other'])) + ->andReturn(true); + + $ids = $this->service->supersedConflicts($conflicts, 'entry-1'); + + expect($ids)->toBe(['c1']); + }); + }); +});