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
34 changes: 18 additions & 16 deletions app/Commands/SynthesizeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -78,15 +79,15 @@ 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);

info("Scanning for duplicates (similarity >= {$similarity})...");

// 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...'
);

Expand All @@ -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)
Expand All @@ -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++;
}

Expand All @@ -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.");
Expand All @@ -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...'
);

Expand Down Expand Up @@ -191,7 +193,7 @@ private function runDigest(QdrantService $qdrant, bool $dryRun): bool
'priority' => 'medium',
'confidence' => 85,
'status' => 'validated',
]);
], $project);

info("Digest created for {$today}");

Expand All @@ -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);
Expand All @@ -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...'
);

Expand All @@ -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++;
Expand All @@ -242,12 +244,12 @@ private function runArchiveStale(QdrantService $qdrant, bool $dryRun): int
*
* @return Collection<int, array<string, mixed>>
*/
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 {
Expand Down
83 changes: 60 additions & 23 deletions tests/Feature/Commands/SynthesizeCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});

Expand All @@ -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')
Expand All @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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])
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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])
Expand Down Expand Up @@ -275,33 +280,65 @@
// 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'])
->assertSuccessful();
});
});

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')
Expand Down Expand Up @@ -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());
}
Loading