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
39 changes: 39 additions & 0 deletions app/Commands/Concerns/ResolvesProject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace App\Commands\Concerns;

use App\Services\ProjectDetectorService;

/**
* Provides --project and --global flag resolution for commands.
*
* Commands using this trait should include these in their signature:
* {--project= : Override project namespace}
* {--global : Search across all projects}
*/
trait ResolvesProject
{
/**
* Resolve the project name from --project flag or auto-detection.
*/
protected function resolveProject(): string
{
$projectOption = $this->option('project');

if (is_string($projectOption) && $projectOption !== '') {
return $projectOption;
}
Comment on lines +23 to +27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

User-supplied --project value is not sanitized.

resolveProject() returns the raw --project option value, while auto-detected names go through ProjectDetectorService::sanitize() (lowercase, special-char replacement, etc.). A user passing --project="My Project!" would produce an unsanitized collection name, potentially causing Qdrant errors or inconsistent namespacing.

Consider running the same sanitization on user-provided values:

Proposed fix
     protected function resolveProject(): string
     {
         $projectOption = $this->option('project');
 
         if (is_string($projectOption) && $projectOption !== '') {
-            return $projectOption;
+            return app(ProjectDetectorService::class)->sanitize($projectOption);
         }
 
         return app(ProjectDetectorService::class)->detect();
     }

This requires making sanitize() public in ProjectDetectorService (it's currently private).

🤖 Prompt for AI Agents
In `@app/Commands/Concerns/ResolvesProject.php` around lines 23 - 27, The
resolveProject() path returns the raw --project option without the same
normalization used for auto-detected names; call
ProjectDetectorService::sanitize() on the user-supplied value before returning
to ensure consistent, safe project/collection names. Make the sanitize() method
on ProjectDetectorService public (it is currently private) so resolveProject()
can invoke it, then replace the direct return of $projectOption in
resolveProject() with the sanitized result from
ProjectDetectorService::sanitize($projectOption), preserving existing behavior
for empty/non-string options.


return app(ProjectDetectorService::class)->detect();
}

/**
* Check if --global flag is set.
*/
protected function isGlobal(): bool
{
return (bool) $this->option('global');
}
}
7 changes: 6 additions & 1 deletion app/Commands/InsightsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Commands;

use App\Commands\Concerns\ResolvesProject;
use App\Services\PatternDetectorService;
use App\Services\QdrantService;
use App\Services\ThemeClassifierService;
Expand All @@ -17,11 +18,15 @@

class InsightsCommand extends Command
{
use ResolvesProject;

protected $signature = 'insights
{--themes : Show theme classification analysis}
{--patterns : Show pattern detection analysis}
{--classify-entry= : Classify a specific entry by ID}
{--limit=100 : Number of entries to analyze}';
{--limit=100 : Number of entries to analyze}
{--project= : Override project namespace}
{--global : Search across all projects}';

protected $description = 'Analyze knowledge base for themes, patterns, and insights';

Expand Down
9 changes: 7 additions & 2 deletions app/Commands/KnowledgeAddCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Commands;

use App\Commands\Concerns\ResolvesProject;
use App\Exceptions\Qdrant\DuplicateEntryException;
use App\Services\EnhancementQueueService;
use App\Services\GitContextService;
Expand All @@ -20,6 +21,8 @@

class KnowledgeAddCommand extends Command
{
use ResolvesProject;

protected $signature = 'add
{title : The title of the knowledge entry}
{--content= : The content of the knowledge entry}
Expand All @@ -38,7 +41,9 @@ class KnowledgeAddCommand extends Command
{--commit= : Git commit hash}
{--no-git : Skip automatic git context detection}
{--force : Skip write gate and duplicate detection}
{--skip-enhance : Skip queueing for Ollama enhancement}';
{--skip-enhance : Skip queueing for Ollama enhancement}
{--project= : Override project namespace}
{--global : Search across all projects}';

protected $description = 'Add a new knowledge entry';

Expand Down Expand Up @@ -172,7 +177,7 @@ public function handle(GitContextService $gitService, QdrantService $qdrant, Wri
$checkDuplicates = ! $force;
try {
$success = spin(
fn (): bool => $qdrant->upsert($data, 'default', $checkDuplicates),
fn (): bool => $qdrant->upsert($data, $this->resolveProject(), $checkDuplicates),
'Storing knowledge entry...'
);

Expand Down
14 changes: 10 additions & 4 deletions app/Commands/KnowledgeArchiveCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@

namespace App\Commands;

use App\Commands\Concerns\ResolvesProject;
use App\Services\QdrantService;
use LaravelZero\Framework\Commands\Command;

class KnowledgeArchiveCommand extends Command
{
use ResolvesProject;

/**
* @var string
*/
protected $signature = 'archive
{id : The ID of the entry to archive}
{--restore : Restore an archived entry}';
{--restore : Restore an archived entry}
{--project= : Override project namespace}
{--global : Search across all projects}';
Comment on lines 18 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's read the full KnowledgeArchiveCommand.php file
cat -n app/Commands/KnowledgeArchiveCommand.php

Repository: conduit-ui/knowledge

Length of output: 4101


🏁 Script executed:

# Run the provided verification script to check isGlobal() usage
rg -n 'isGlobal\(\)' --type=php -g '!tests/**'

Repository: conduit-ui/knowledge

Length of output: 224


Remove the unused --global flag from the command signature or implement its functionality.

The --global flag is declared in the signature (line 22) but never used in the handle() method or helper methods. The command always searches within a resolved project via $this->resolveProject() and ignores the flag. The isGlobal() method exists in the ResolvesProject trait and is used in other commands (e.g., KnowledgeSearchCommand), so the pattern is established—either implement the global search behavior here or remove the declaration to avoid misleading users.

🤖 Prompt for AI Agents
In `@app/Commands/KnowledgeArchiveCommand.php` around lines 18 - 22, The signature
currently declares a --global flag that is never used; either remove it from the
protected $signature in KnowledgeArchiveCommand or implement its behavior in
handle(): use $this->isGlobal() (from the ResolvesProject trait) to decide
whether to call $this->resolveProject() (when not global) or to perform a global
lookup (when global), and adjust any subsequent calls that expect a project
(e.g., archive/restore logic) to accept a null/optional project or route to a
global repository method; update handle(), any helper methods, and input
validation accordingly so the flag is either removed or actually toggles global
vs project-scoped behavior.


/**
* @var string
Expand All @@ -34,7 +39,8 @@ public function handle(QdrantService $qdrant): int
return self::FAILURE;
}

$entry = $qdrant->getById((int) $id);
$project = $this->resolveProject();
$entry = $qdrant->getById((int) $id, $project);

if ($entry === null) {
$this->error("Entry not found with ID: {$id}");
Expand Down Expand Up @@ -67,7 +73,7 @@ private function archiveEntry(QdrantService $qdrant, array $entry): int
$qdrant->updateFields((int) $entry['id'], [
'status' => 'deprecated',
'confidence' => 0,
]);
], $this->resolveProject());

$this->info("Entry #{$entry['id']} has been archived.");
$this->newLine();
Expand Down Expand Up @@ -95,7 +101,7 @@ private function restoreEntry(QdrantService $qdrant, array $entry): int
$qdrant->updateFields((int) $entry['id'], [
'status' => 'draft',
'confidence' => 50,
]);
], $this->resolveProject());

$this->info("Entry #{$entry['id']} has been restored.");
$this->newLine();
Expand Down
9 changes: 7 additions & 2 deletions app/Commands/KnowledgeExportAllCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,24 @@

namespace App\Commands;

use App\Commands\Concerns\ResolvesProject;
use App\Services\MarkdownExporter;
use App\Services\QdrantService;
use LaravelZero\Framework\Commands\Command;

class KnowledgeExportAllCommand extends Command
{
use ResolvesProject;

/**
* @var string
*/
protected $signature = 'export:all
{--format=markdown : Export format (markdown, json)}
{--output=./docs : Output directory path}
{--category= : Export only entries from a specific category}';
{--category= : Export only entries from a specific category}
{--project= : Override project namespace}
{--global : Search across all projects}';
Comment on lines +22 to +24
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

--global flag is declared but never used — it's a no-op.

The signature advertises --global for searching across all projects, but handle() never calls $this->isGlobal(). The export always scopes to the single resolved project regardless of whether --global is passed. This will confuse users who expect know export:all --global to export from all projects.

Either implement the global export logic (similar to the loop in KnowledgeSearchCommand) or remove the --global flag from this command's signature.

Also applies to: 47-47

🤖 Prompt for AI Agents
In `@app/Commands/KnowledgeExportAllCommand.php` around lines 22 - 24, The
--global flag is declared but never used in KnowledgeExportAllCommand: update
the handle() method to respect $this->isGlobal() (or remove the flag);
specifically, either implement the global export loop similar to
KnowledgeSearchCommand (iterate all projects when isGlobal() returns true and
perform the existing export logic per project) or remove the --global option
from the command signature; look for methods and symbols
KnowledgeExportAllCommand::handle(), $this->isGlobal(), and the
project-resolution/export loop to apply the fix.


/**
* @var string
Expand All @@ -39,7 +44,7 @@ public function handle(MarkdownExporter $markdownExporter, QdrantService $qdrant
}

// Get all entries (use high limit)
$entries = $qdrant->search('', $filters, 10000);
$entries = $qdrant->search('', $filters, 10000, $this->resolveProject());

if ($entries->isEmpty()) {
$this->warn('No entries found to export.');
Expand Down
9 changes: 7 additions & 2 deletions app/Commands/KnowledgeExportCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,24 @@

namespace App\Commands;

use App\Commands\Concerns\ResolvesProject;
use App\Services\MarkdownExporter;
use App\Services\QdrantService;
use LaravelZero\Framework\Commands\Command;

class KnowledgeExportCommand extends Command
{
use ResolvesProject;

/**
* @var string
*/
protected $signature = 'export
{id : The ID of the knowledge entry to export}
{--format=markdown : Export format (markdown, json)}
{--output= : Output file path (default: stdout)}';
{--output= : Output file path (default: stdout)}
{--project= : Override project namespace}
{--global : Search across all projects}';

/**
* @var string
Expand All @@ -39,7 +44,7 @@ public function handle(MarkdownExporter $markdownExporter): int
return self::FAILURE;
}

$entry = app(QdrantService::class)->getById((int) $id);
$entry = app(QdrantService::class)->getById((int) $id, $this->resolveProject());

if ($entry === null) {
$this->error('Entry not found.');
Expand Down
9 changes: 7 additions & 2 deletions app/Commands/KnowledgeListCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Commands;

use App\Commands\Concerns\ResolvesProject;
use App\Services\QdrantService;
use LaravelZero\Framework\Commands\Command;

Expand All @@ -13,6 +14,8 @@

class KnowledgeListCommand extends Command
{
use ResolvesProject;

/**
* @var string
*/
Expand All @@ -22,7 +25,9 @@ class KnowledgeListCommand extends Command
{--status= : Filter by status}
{--module= : Filter by module}
{--limit=20 : Maximum number of entries to display}
{--offset= : Skip N entries (use point ID for pagination)}';
{--offset= : Skip N entries (use point ID for pagination)}
{--project= : Override project namespace}
{--global : Search across all projects}';

/**
* @var string
Expand Down Expand Up @@ -51,7 +56,7 @@ public function handle(QdrantService $qdrant): int

// Use scroll to get entries (no vector search needed)
$results = spin(
fn (): \Illuminate\Support\Collection => $qdrant->scroll($filters, $limit, 'default', $parsedOffset),
fn (): \Illuminate\Support\Collection => $qdrant->scroll($filters, $limit, $this->resolveProject(), $parsedOffset),
'Fetching entries...'
);

Expand Down
58 changes: 26 additions & 32 deletions app/Commands/KnowledgeSearchCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

namespace App\Commands;

use App\Enums\SearchTier;
use App\Commands\Concerns\ResolvesProject;
use App\Services\EntryMetadataService;
use App\Services\TieredSearchService;
use App\Services\QdrantService;
use LaravelZero\Framework\Commands\Command;

class KnowledgeSearchCommand extends Command
{
use ResolvesProject;

/**
* @var string
*/
Expand All @@ -24,14 +26,15 @@ class KnowledgeSearchCommand extends Command
{--limit=20 : Maximum number of results}
{--semantic : Use semantic search if available}
{--include-superseded : Include superseded entries in results}
{--tier= : Force searching a specific tier (working, recent, structured, archive)}';
{--project= : Override project namespace}
{--global : Search across all projects}';

/**
* @var string
*/
protected $description = 'Search knowledge entries by keyword, tag, or category';

public function handle(TieredSearchService $tieredSearch, EntryMetadataService $metadata): int
public function handle(QdrantService $qdrant, EntryMetadataService $metadata): int
{
$query = $this->argument('query');
$tag = $this->option('tag');
Expand All @@ -42,7 +45,6 @@ public function handle(TieredSearchService $tieredSearch, EntryMetadataService $
$limit = (int) $this->option('limit');
$this->option('semantic');
$includeSuperseded = (bool) $this->option('include-superseded');
$tierOption = $this->option('tier');

// Require at least one search parameter for entries
if ($query === null && $tag === null && $category === null && $module === null && $priority === null && $status === null) {
Expand All @@ -51,18 +53,6 @@ public function handle(TieredSearchService $tieredSearch, EntryMetadataService $
return self::FAILURE;
}

// Validate tier option
$forceTier = null;
if (is_string($tierOption) && $tierOption !== '') {
$forceTier = SearchTier::tryFrom($tierOption);
if ($forceTier === null) {
$validTiers = implode(', ', array_map(fn (SearchTier $t): string => $t->value, SearchTier::cases()));
$this->error("Invalid tier '{$tierOption}'. Valid tiers: {$validTiers}");

return self::FAILURE;
}
}

// Build filters for search
$filters = array_filter([
'tag' => is_string($tag) ? $tag : null,
Expand All @@ -76,9 +66,24 @@ public function handle(TieredSearchService $tieredSearch, EntryMetadataService $
$filters['include_superseded'] = true;
}

// Use tiered search
// Use project-aware search
$searchQuery = is_string($query) ? $query : '';
$results = $tieredSearch->search($searchQuery, $filters, $limit, $forceTier);

if ($this->isGlobal()) {
$collections = $qdrant->listCollections();
$results = collect();

foreach ($collections as $collection) {
$projectName = str_replace('knowledge_', '', $collection);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

str_replace strips all occurrences of knowledge_, not just the prefix.

If a repo is named knowledge_base, the collection would be knowledge_knowledge_base, and str_replace('knowledge_', '', ...) would yield base instead of knowledge_base. Use a prefix-only strip instead.

🔧 Proposed fix
-            $projectName = str_replace('knowledge_', '', $collection);
+            $projectName = str_starts_with($collection, 'knowledge_')
+                ? substr($collection, strlen('knowledge_'))
+                : $collection;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$projectName = str_replace('knowledge_', '', $collection);
$projectName = str_starts_with($collection, 'knowledge_')
? substr($collection, strlen('knowledge_'))
: $collection;
🤖 Prompt for AI Agents
In `@app/Commands/KnowledgeSearchCommand.php` at line 77, The current use of
str_replace('knowledge_', '', $collection) in KnowledgeSearchCommand (where
$projectName is set) removes every occurrence of "knowledge_" instead of only
the prefix; change it to strip the prefix only—either check with
str_starts_with($collection, 'knowledge_') and then use substr($collection,
strlen('knowledge_')) to set $projectName, or use a prefix-only regex like
preg_replace('/^knowledge_/', '', $collection) so names like
"knowledge_knowledge_base" become "knowledge_base".

$projectResults = $qdrant->search($searchQuery, $filters, $limit, $projectName);
$results = $results->merge($projectResults->map(fn (array $entry): array => array_merge($entry, ['_project' => $projectName])));
}

$results = $results->sortByDesc('score')->take($limit)->values();
} else {
$project = $this->resolveProject();
$results = $qdrant->search($searchQuery, $filters, $limit, $project);
}

if ($results->isEmpty()) {
$this->line('No entries found.');
Expand All @@ -100,24 +105,13 @@ public function handle(TieredSearchService $tieredSearch, EntryMetadataService $
$content = $entry['content'] ?? '';
$score = $entry['score'] ?? 0.0;
$supersededBy = $entry['superseded_by'] ?? null;
$tierLabel = $entry['tier_label'] ?? null;
$tieredScore = $entry['tiered_score'] ?? null;

$isStale = $metadata->isStale($entry);
$effectiveConfidence = $metadata->calculateEffectiveConfidence($entry);
$confidenceLevel = $metadata->confidenceLevel($effectiveConfidence);

$scoreDisplay = 'score: '.number_format($score, 2);
if ($tieredScore !== null) {
$scoreDisplay .= ' | ranked: '.number_format((float) $tieredScore, 2);
}

$tierDisplay = '';
if (is_string($tierLabel)) {
$tierDisplay = " <fg=magenta>[{$tierLabel}]</>";
}

$titleLine = "<fg=cyan>[{$id}]</> <fg=green>{$title}</> <fg=yellow>({$scoreDisplay})</>{$tierDisplay}";
$projectLabel = isset($entry['_project']) ? " <fg=magenta>[{$entry['_project']}]</>" : '';
$titleLine = "<fg=cyan>[{$id}]</> <fg=green>{$title}</> <fg=yellow>(score: ".number_format($score, 2).')</>'.$projectLabel;
if ($supersededBy !== null) {
$titleLine .= ' <fg=red>[SUPERSEDED]</>';
}
Expand Down
Loading
Loading