From 5e3b321de6a9119de0d5a44318f98ce49e734cc6 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Thu, 2 Jul 2026 21:37:19 -0700 Subject: [PATCH] fix: thread resolved project through synthesize operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synthesize command declared --project and imported ResolvesProject but never used either — every scroll/search/upsert/updateFields call fell back to the 'default' collection. Since entries are stored in per-project collections, the nightly digest/dedupe/archive has been running against a collection where no new entries land: 'Digest Created: No' every night with exit 0. All eight Qdrant call sites now receive the resolved project, test expectations pin the project argument, and a regression test drives --project through every operation. --- app/Commands/SynthesizeCommand.php | 34 ++++---- .../Commands/SynthesizeCommandTest.php | 83 ++++++++++++++----- 2 files changed, 78 insertions(+), 39 deletions(-) diff --git a/app/Commands/SynthesizeCommand.php b/app/Commands/SynthesizeCommand.php index dd2b258..262cc71 100644 --- a/app/Commands/SynthesizeCommand.php +++ b/app/Commands/SynthesizeCommand.php @@ -45,6 +45,7 @@ public function handle(QdrantService $qdrant): int $digest = (bool) $this->option('digest'); $archiveStale = (bool) $this->option('archive-stale'); $dryRun = (bool) $this->option('dry-run'); + $project = $this->resolveProject(); // If no specific option, run all $runAll = ! $dedupe && ! $digest && ! $archiveStale; @@ -57,15 +58,15 @@ public function handle(QdrantService $qdrant): int ]; if ($runAll || $dedupe) { - $stats = array_merge($stats, $this->runDedupe($qdrant, $dryRun)); + $stats = array_merge($stats, $this->runDedupe($qdrant, $dryRun, $project)); } if ($runAll || $digest) { - $stats['digest_created'] = $this->runDigest($qdrant, $dryRun); + $stats['digest_created'] = $this->runDigest($qdrant, $dryRun, $project); } if ($runAll || $archiveStale) { - $stats['stale_archived'] = $this->runArchiveStale($qdrant, $dryRun); + $stats['stale_archived'] = $this->runArchiveStale($qdrant, $dryRun, $project); } $this->displaySummary($stats, $dryRun); @@ -78,7 +79,7 @@ public function handle(QdrantService $qdrant): int * * @return array{duplicates_found: int, duplicates_merged: int} */ - private function runDedupe(QdrantService $qdrant, bool $dryRun): array + private function runDedupe(QdrantService $qdrant, bool $dryRun, string $project): array { $similarity = (float) ($this->option('similarity') ?? self::DEDUPE_SIMILARITY_DEFAULT); @@ -86,7 +87,7 @@ private function runDedupe(QdrantService $qdrant, bool $dryRun): array // Get all entries with low confidence (these are candidates for deduplication) $candidates = spin( - fn (): \Illuminate\Support\Collection => $qdrant->scroll(['status' => 'draft'], 100), + fn (): \Illuminate\Support\Collection => $qdrant->scroll(['status' => 'draft'], 100, $project), 'Fetching draft entries...' ); @@ -106,7 +107,8 @@ private function runDedupe(QdrantService $qdrant, bool $dryRun): array $similar = $qdrant->search( $candidate['title'].' '.$candidate['content'], [], - 10 + 10, + $project ); // Find duplicates (high similarity, different ID, higher confidence) @@ -123,7 +125,7 @@ private function runDedupe(QdrantService $qdrant, bool $dryRun): array $qdrant->updateFields($id, [ 'status' => 'deprecated', 'content' => $candidate['content']."\n\n[Merged into: ".$best['id'].']', - ]); + ], $project); $duplicatesMerged++; } @@ -143,14 +145,14 @@ private function runDedupe(QdrantService $qdrant, bool $dryRun): array /** * Generate a daily digest of recent high-value entries. */ - private function runDigest(QdrantService $qdrant, bool $dryRun): bool + private function runDigest(QdrantService $qdrant, bool $dryRun, string $project): bool { $today = Carbon::today()->format('Y-m-d'); info("Generating digest for {$today}..."); // Check if digest already exists for today - $existing = $qdrant->search("Daily Synthesis - {$today}", ['tag' => 'daily-synthesis'], 1); + $existing = $qdrant->search("Daily Synthesis - {$today}", ['tag' => 'daily-synthesis'], 1, $project); if ($existing->isNotEmpty() && Str::contains($existing->first()['title'], $today)) { warning("Digest for {$today} already exists, skipping."); @@ -160,7 +162,7 @@ private function runDigest(QdrantService $qdrant, bool $dryRun): bool // Get recent validated/high-confidence entries from last 24 hours $recentEntries = spin( - fn (): \Illuminate\Support\Collection => $this->getRecentHighValueEntries($qdrant), + fn (): \Illuminate\Support\Collection => $this->getRecentHighValueEntries($qdrant, $project), 'Analyzing recent entries...' ); @@ -191,7 +193,7 @@ private function runDigest(QdrantService $qdrant, bool $dryRun): bool 'priority' => 'medium', 'confidence' => 85, 'status' => 'validated', - ]); + ], $project); info("Digest created for {$today}"); @@ -201,7 +203,7 @@ private function runDigest(QdrantService $qdrant, bool $dryRun): bool /** * Archive stale low-confidence entries. */ - private function runArchiveStale(QdrantService $qdrant, bool $dryRun): int + private function runArchiveStale(QdrantService $qdrant, bool $dryRun, string $project): int { $staleDays = (int) ($this->option('stale-days') ?? self::STALE_DAYS_DEFAULT); $confidenceFloor = (int) ($this->option('confidence-floor') ?? self::CONFIDENCE_FLOOR_DEFAULT); @@ -212,7 +214,7 @@ private function runArchiveStale(QdrantService $qdrant, bool $dryRun): int // Get draft entries $candidates = spin( - fn (): \Illuminate\Support\Collection => $qdrant->scroll(['status' => 'draft'], 200), + fn (): \Illuminate\Support\Collection => $qdrant->scroll(['status' => 'draft'], 200, $project), 'Scanning for stale entries...' ); @@ -226,7 +228,7 @@ private function runArchiveStale(QdrantService $qdrant, bool $dryRun): int if ($isOld && $isLowConfidence && $isUnused) { if (! $dryRun) { - $qdrant->updateFields($entry['id'], ['status' => 'deprecated']); + $qdrant->updateFields($entry['id'], ['status' => 'deprecated'], $project); } $archived++; @@ -242,12 +244,12 @@ private function runArchiveStale(QdrantService $qdrant, bool $dryRun): int * * @return Collection> */ - private function getRecentHighValueEntries(QdrantService $qdrant): Collection + private function getRecentHighValueEntries(QdrantService $qdrant, string $project): Collection { $yesterday = Carbon::yesterday()->toIso8601String(); // Get validated entries - $validated = $qdrant->scroll(['status' => 'validated'], 50); + $validated = $qdrant->scroll(['status' => 'validated'], 50, $project); // Filter to recent and high confidence return $validated->filter(function (array $entry) use ($yesterday): bool { diff --git a/tests/Feature/Commands/SynthesizeCommandTest.php b/tests/Feature/Commands/SynthesizeCommandTest.php index 8e8b16f..1e9db86 100644 --- a/tests/Feature/Commands/SynthesizeCommandTest.php +++ b/tests/Feature/Commands/SynthesizeCommandTest.php @@ -2,12 +2,17 @@ declare(strict_types=1); +use App\Services\ProjectDetectorService; use App\Services\QdrantService; use Carbon\Carbon; beforeEach(function (): void { $this->qdrantMock = Mockery::mock(QdrantService::class); $this->app->instance(QdrantService::class, $this->qdrantMock); + + $detector = Mockery::mock(ProjectDetectorService::class); + $detector->shouldReceive('detect')->andReturn('default'); + $this->app->instance(ProjectDetectorService::class, $detector); Carbon::setTestNow(Carbon::parse('2026-02-03 10:00:00')); }); @@ -22,7 +27,7 @@ $this->qdrantMock->shouldReceive('scroll') ->once() - ->with(['status' => 'draft'], 100) + ->with(['status' => 'draft'], 100, 'default') ->andReturn(collect([$candidate])); $this->qdrantMock->shouldReceive('search') @@ -31,7 +36,7 @@ $this->qdrantMock->shouldReceive('updateFields') ->once() - ->with('1', Mockery::on(fn ($fields): bool => $fields['status'] === 'deprecated')) + ->with('1', Mockery::on(fn ($fields): bool => $fields['status'] === 'deprecated'), 'default') ->andReturn(true); // Mock digest and archive operations to return empty @@ -47,7 +52,7 @@ $this->qdrantMock->shouldReceive('scroll') ->once() - ->with(['status' => 'draft'], 100) + ->with(['status' => 'draft'], 100, 'default') ->andReturn(collect([$candidate])); $this->qdrantMock->shouldReceive('search') @@ -91,7 +96,7 @@ $higherConfidenceMatch = createEntry('2', 'Better Entry', 80, 'validated', 0.95); $this->qdrantMock->shouldReceive('scroll') - ->with(['status' => 'draft'], 100) + ->with(['status' => 'draft'], 100, 'default') ->andReturn(collect([$candidate, $duplicateCandidate])); // First iteration: finds a match and processes it @@ -103,7 +108,7 @@ // Only one merge should happen $this->qdrantMock->shouldReceive('updateFields') ->once() - ->with('1', Mockery::on(fn ($fields): bool => $fields['status'] === 'deprecated')) + ->with('1', Mockery::on(fn ($fields): bool => $fields['status'] === 'deprecated'), 'default') ->andReturn(true); mockEmptyDigestAndArchive($this->qdrantMock); @@ -117,7 +122,7 @@ it('creates a daily digest', function (): void { // No existing digest $this->qdrantMock->shouldReceive('search') - ->with('Daily Synthesis - 2026-02-03', ['tag' => 'daily-synthesis'], 1) + ->with('Daily Synthesis - 2026-02-03', ['tag' => 'daily-synthesis'], 1, 'default') ->andReturn(collect()); // Recent validated entries @@ -127,14 +132,14 @@ ]); $this->qdrantMock->shouldReceive('scroll') - ->with(['status' => 'validated'], 50) + ->with(['status' => 'validated'], 50, 'default') ->andReturn($entries); $this->qdrantMock->shouldReceive('upsert') ->once() ->with(Mockery::on(fn ($data): bool => str_contains((string) $data['title'], 'Daily Synthesis - 2026-02-03') && $data['status'] === 'validated' - && in_array('daily-synthesis', $data['tags']))) + && in_array('daily-synthesis', $data['tags'])), 'default') ->andReturn(true); $this->artisan('synthesize', ['--digest' => true]) @@ -145,7 +150,7 @@ $existingDigest = createEntry('existing', 'Daily Synthesis - 2026-02-03', 85, 'validated'); $this->qdrantMock->shouldReceive('search') - ->with('Daily Synthesis - 2026-02-03', ['tag' => 'daily-synthesis'], 1) + ->with('Daily Synthesis - 2026-02-03', ['tag' => 'daily-synthesis'], 1, 'default') ->andReturn(collect([$existingDigest])); // Should NOT create new digest @@ -171,7 +176,7 @@ it('shows digest preview in dry-run mode', function (): void { // No existing digest $this->qdrantMock->shouldReceive('search') - ->with('Daily Synthesis - 2026-02-03', ['tag' => 'daily-synthesis'], 1) + ->with('Daily Synthesis - 2026-02-03', ['tag' => 'daily-synthesis'], 1, 'default') ->andReturn(collect()); // Recent validated entries with high confidence @@ -180,7 +185,7 @@ ]); $this->qdrantMock->shouldReceive('scroll') - ->with(['status' => 'validated'], 50) + ->with(['status' => 'validated'], 50, 'default') ->andReturn($entries); // Should NOT upsert in dry-run mode @@ -194,7 +199,7 @@ it('truncates long content in digest preview', function (): void { // No existing digest $this->qdrantMock->shouldReceive('search') - ->with('Daily Synthesis - 2026-02-03', ['tag' => 'daily-synthesis'], 1) + ->with('Daily Synthesis - 2026-02-03', ['tag' => 'daily-synthesis'], 1, 'default') ->andReturn(collect()); // Entry with long content (>100 chars) @@ -203,7 +208,7 @@ $entryWithLongContent['content'] = $longContent; $this->qdrantMock->shouldReceive('scroll') - ->with(['status' => 'validated'], 50) + ->with(['status' => 'validated'], 50, 'default') ->andReturn(collect([$entryWithLongContent])); // Should NOT upsert in dry-run mode @@ -222,12 +227,12 @@ $staleEntry['usage_count'] = 0; $this->qdrantMock->shouldReceive('scroll') - ->with(['status' => 'draft'], 200) + ->with(['status' => 'draft'], 200, 'default') ->andReturn(collect([$staleEntry])); $this->qdrantMock->shouldReceive('updateFields') ->once() - ->with('1', ['status' => 'deprecated']) + ->with('1', ['status' => 'deprecated'], 'default') ->andReturn(true); $this->artisan('synthesize', ['--archive-stale' => true]) @@ -275,7 +280,7 @@ // With 7 day threshold, this entry IS stale $this->qdrantMock->shouldReceive('updateFields') ->once() - ->with('1', ['status' => 'deprecated']) + ->with('1', ['status' => 'deprecated'], 'default') ->andReturn(true); $this->artisan('synthesize', ['--archive-stale' => true, '--stale-days' => '7']) @@ -283,25 +288,57 @@ }); }); +describe('project scoping', function (): void { + it('threads the resolved project through every qdrant operation', function (): void { + $this->qdrantMock->shouldReceive('scroll') + ->once() + ->with(['status' => 'draft'], 100, 'homelab') + ->andReturn(collect()); + + $this->qdrantMock->shouldReceive('search') + ->once() + ->with('Daily Synthesis - 2026-02-03', ['tag' => 'daily-synthesis'], 1, 'homelab') + ->andReturn(collect()); + + $this->qdrantMock->shouldReceive('scroll') + ->once() + ->with(['status' => 'validated'], 50, 'homelab') + ->andReturn(collect([createEntry('9', 'High Value Win', 90, 'validated')])); + + $this->qdrantMock->shouldReceive('upsert') + ->once() + ->with(Mockery::type('array'), 'homelab') + ->andReturn(true); + + $this->qdrantMock->shouldReceive('scroll') + ->once() + ->with(['status' => 'draft'], 200, 'homelab') + ->andReturn(collect()); + + $this->artisan('synthesize', ['--project' => 'homelab']) + ->assertSuccessful(); + }); +}); + describe('full run', function (): void { it('runs all operations when no flags specified', function (): void { // Dedupe $this->qdrantMock->shouldReceive('scroll') - ->with(['status' => 'draft'], 100) + ->with(['status' => 'draft'], 100, 'default') ->andReturn(collect()); // Digest $this->qdrantMock->shouldReceive('search') - ->with('Daily Synthesis - 2026-02-03', ['tag' => 'daily-synthesis'], 1) + ->with('Daily Synthesis - 2026-02-03', ['tag' => 'daily-synthesis'], 1, 'default') ->andReturn(collect()); $this->qdrantMock->shouldReceive('scroll') - ->with(['status' => 'validated'], 50) + ->with(['status' => 'validated'], 50, 'default') ->andReturn(collect()); // Archive $this->qdrantMock->shouldReceive('scroll') - ->with(['status' => 'draft'], 200) + ->with(['status' => 'draft'], 200, 'default') ->andReturn(collect()); $this->artisan('synthesize') @@ -338,15 +375,15 @@ function mockEmptyDigestAndArchive(Mockery\MockInterface $mock): void { // Digest check $mock->shouldReceive('search') - ->with(Mockery::pattern('/Daily Synthesis/'), Mockery::any(), 1) + ->with(Mockery::pattern('/Daily Synthesis/'), Mockery::any(), 1, 'default') ->andReturn(collect()); $mock->shouldReceive('scroll') - ->with(['status' => 'validated'], 50) + ->with(['status' => 'validated'], 50, 'default') ->andReturn(collect()); // Archive stale $mock->shouldReceive('scroll') - ->with(['status' => 'draft'], 200) + ->with(['status' => 'draft'], 200, 'default') ->andReturn(collect()); }