-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement project-aware namespacing #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7688299
30e5ec7
1379147
9d28112
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } | ||
|
|
||
| return app(ProjectDetectorService::class)->detect(); | ||
| } | ||
|
|
||
| /** | ||
| * Check if --global flag is set. | ||
| */ | ||
| protected function isGlobal(): bool | ||
| { | ||
| return (bool) $this->option('global'); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's read the full KnowledgeArchiveCommand.php file
cat -n app/Commands/KnowledgeArchiveCommand.phpRepository: 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 The 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * @var string | ||
|
|
@@ -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}"); | ||
|
|
@@ -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(); | ||
|
|
@@ -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(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The signature advertises Either implement the global export logic (similar to the loop in Also applies to: 47-47 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * @var string | ||
|
|
@@ -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.'); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||
| */ | ||||||||||
|
|
@@ -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'); | ||||||||||
|
|
@@ -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) { | ||||||||||
|
|
@@ -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, | ||||||||||
|
|
@@ -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); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a repo is named 🔧 Proposed fix- $projectName = str_replace('knowledge_', '', $collection);
+ $projectName = str_starts_with($collection, 'knowledge_')
+ ? substr($collection, strlen('knowledge_'))
+ : $collection;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| $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.'); | ||||||||||
|
|
@@ -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]</>'; | ||||||||||
| } | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
User-supplied
--projectvalue is not sanitized.resolveProject()returns the raw--projectoption value, while auto-detected names go throughProjectDetectorService::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 inProjectDetectorService(it's currentlyprivate).🤖 Prompt for AI Agents