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
104 changes: 93 additions & 11 deletions app/Commands/KnowledgeAddCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $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<string>|null $tags
*/
private function displayEntryTable(
string $id,
string $title,
?string $category,
string $priority,
int $confidence,
?array $tags
): void {
table(
['Field', 'Value'],
[
Expand All @@ -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;
}
}
20 changes: 18 additions & 2 deletions app/Commands/KnowledgeSearchCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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("<fg=cyan>[{$id}]</> <fg=green>{$title}</> <fg=yellow>(score: ".number_format($score, 2).')</>');
$titleLine = "<fg=cyan>[{$id}]</> <fg=green>{$title}</> <fg=yellow>(score: ".number_format($score, 2).')</>';
if ($supersededBy !== null) {
$titleLine .= ' <fg=red>[SUPERSEDED]</>';
}

$this->line($titleLine);

if ($isStale) {
$days = $metadata->daysSinceVerification($entry);
Expand All @@ -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("<fg=gray>Superseded by: {$supersededBy}</>");
}

if ($module !== null) {
$this->line("Module: {$module}");
}
Expand Down
65 changes: 61 additions & 4 deletions app/Commands/KnowledgeShowCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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...'
Expand All @@ -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<string, mixed> $entry
*/
private function renderEntry(array $entry, EntryMetadataService $metadata): void
{
$this->newLine();
$this->line("<fg=cyan;options=bold>{$entry['title']}</>");

$titleLine = "<fg=cyan;options=bold>{$entry['title']}</>";
$supersededBy = $entry['superseded_by'] ?? null;
if ($supersededBy !== null && $supersededBy !== '') {
$titleLine .= ' <fg=red>[SUPERSEDED]</>';
}
$this->line($titleLine);
$this->line("<fg=gray>ID: {$entry['id']}</>");
$this->newLine();

Expand Down Expand Up @@ -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<string> $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);
Expand All @@ -90,6 +117,36 @@ private function renderEntry(array $entry, EntryMetadataService $metadata): void
$this->line("<fg=gray>Created: {$entry['created_at']} | Updated: {$entry['updated_at']}</>");
}

/**
* @param array<string, mixed> $entry
* @param array{supersedes: array<int, array<string, mixed>>, superseded_by: array<string, mixed>|null} $history
*/
private function renderSupersessionHistory(array $entry, array $history): void
{
$hasHistory = $history['supersedes'] !== [] || $history['superseded_by'] !== null;

if (! $hasHistory) {
return;
}

$this->newLine();
$this->line('<fg=yellow;options=bold>Supersession History</>');

if ($history['superseded_by'] !== null) {
$successor = $history['superseded_by'];
$this->line('<fg=red> This entry was superseded by:</>');
$this->line(" <fg=cyan>[{$successor['id']}]</> {$successor['title']}");
}

if ($history['supersedes'] !== []) {
$this->line('<fg=green> This entry supersedes:</>');
foreach ($history['supersedes'] as $predecessor) {
$reason = $predecessor['superseded_reason'] ?? 'No reason provided';
$this->line(" <fg=cyan>[{$predecessor['id']}]</> {$predecessor['title']} <fg=gray>({$reason})</>");
}
}
}

private function colorize(string $text, string $color): string
{
return "<fg={$color}>{$text}</>";
Expand Down
Loading
Loading