Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions app/Commands/CorrectCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace App\Commands;

use App\Services\CorrectionService;
use App\Services\QdrantService;
use LaravelZero\Framework\Commands\Command;

use function Laravel\Prompts\error;
use function Laravel\Prompts\spin;

class CorrectCommand extends Command
{
protected $signature = 'correct
{id : The ID of the knowledge entry to correct}
{--new-value= : The corrected content value}';

protected $description = 'Correct an entry and propagate changes to conflicting entries';

public function handle(QdrantService $qdrant, CorrectionService $correction): int
{
$idArg = $this->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<string|int>, 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<string, mixed> $originalEntry
* @param array{corrected_entry_id: string, superseded_ids: array<string|int>, 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']}");
}
}
196 changes: 196 additions & 0 deletions app/Services/CorrectionService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php

declare(strict_types=1);

namespace App\Services;

use Illuminate\Support\Str;

class CorrectionService
{
private const CONFLICT_SIMILARITY_THRESHOLD = 0.85;

private const SUPERSEDED_CONFIDENCE = 10;

public function __construct(
private readonly QdrantService $qdrant,
) {}

/**
* Execute a correction: update the target entry, find and supersede conflicts, create corrected entry, log to daily log.
*
* @return array{corrected_entry_id: string, superseded_ids: array<string|int>, 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<string, mixed> $original
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $conflicts
* @return array<string|int>
*/
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<string, mixed> $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<string, mixed> $original
* @param array<string|int> $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<string> $tags
* @return array<string>
*/
private function appendTag(array $tags, string $tag): array
{
if (! in_array($tag, $tags, true)) {
$tags[] = $tag;
}

return array_values($tags);
}
}
Loading
Loading