From b1f5d8cb4c09c803f376a78b1c2d7d2ad3fb1ce4 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Sun, 10 May 2026 10:04:29 +0200 Subject: [PATCH 1/3] Lazy summarise ContextItems via BYOK-resolved agent (slice 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ContextSummariser (laravel/ai), ContextCompressor (BYOK-aware resolver — no creds → Skipped, never throws), and the queued SummariseContextItemJob. AssetUploader and ContextItemWriter dispatch the job for text-mime/long-text bodies; short bodies are marked Skipped so bodyForContext() falls back to the truncated raw body. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Ai/Agents/ContextSummariser.php | 49 +++++++++ app/Jobs/SummariseContextItemJob.php | 61 +++++++++++ app/Services/Ai/ContextCompressor.php | 103 ++++++++++++++++++ app/Services/Ai/SummariseResult.php | 34 ++++++ app/Services/Context/AssetUploader.php | 21 +++- app/Services/Context/ContextItemWriter.php | 52 ++++++++- prompts/context-summariser.md | 18 +++ .../Feature/ContextItem/AssetUploaderTest.php | 2 + .../ContextItem/ContextItemWriterTest.php | 8 +- .../SummariseContextItemJobTest.php | 88 +++++++++++++++ .../WriterDispatchesSummariseTest.php | 89 +++++++++++++++ 11 files changed, 521 insertions(+), 4 deletions(-) create mode 100644 app/Ai/Agents/ContextSummariser.php create mode 100644 app/Jobs/SummariseContextItemJob.php create mode 100644 app/Services/Ai/ContextCompressor.php create mode 100644 app/Services/Ai/SummariseResult.php create mode 100644 prompts/context-summariser.md create mode 100644 tests/Feature/ContextItem/SummariseContextItemJobTest.php create mode 100644 tests/Feature/ContextItem/WriterDispatchesSummariseTest.php diff --git a/app/Ai/Agents/ContextSummariser.php b/app/Ai/Agents/ContextSummariser.php new file mode 100644 index 0000000..23d36b1 --- /dev/null +++ b/app/Ai/Agents/ContextSummariser.php @@ -0,0 +1,49 @@ +load('context-summariser'); + } + + public function buildPrompt(): string + { + $title = $this->item->title; + $type = $this->item->type?->value ?? 'unknown'; + + return <<body} +PROMPT; + } +} diff --git a/app/Jobs/SummariseContextItemJob.php b/app/Jobs/SummariseContextItemJob.php new file mode 100644 index 0000000..740eb11 --- /dev/null +++ b/app/Jobs/SummariseContextItemJob.php @@ -0,0 +1,61 @@ +find($this->contextItemId); + if ($item === null) { + return; + } + + $result = $compressor->summarise($item, $item->creator); + + $item->forceFill([ + 'summary_status' => $result->status->value, + 'summary' => $result->status === ContextItemSummaryStatus::Ready ? $result->summary : null, + 'summary_error' => $result->status === ContextItemSummaryStatus::Failed ? $result->error : null, + ])->save(); + } + + public function failed(?Throwable $e): void + { + $item = ContextItem::query()->find($this->contextItemId); + if ($item === null) { + return; + } + + Log::error('specify.context.summarise.failed', [ + 'item_id' => $item->getKey(), + 'exception' => $e?->getMessage(), + ]); + + $item->forceFill([ + 'summary_status' => ContextItemSummaryStatus::Failed->value, + 'summary_error' => $e?->getMessage() ?: 'Worker died or retries exhausted.', + ])->save(); + } +} diff --git a/app/Services/Ai/ContextCompressor.php b/app/Services/Ai/ContextCompressor.php new file mode 100644 index 0000000..8273d79 --- /dev/null +++ b/app/Services/Ai/ContextCompressor.php @@ -0,0 +1,103 @@ +resolveBody($item); + if ($body === '') { + return SummariseResult::skipped('Item has no compressible body.'); + } + + if ($actor === null) { + return SummariseResult::skipped('No actor available for BYOK resolution.'); + } + + $providerName = $this->bindUserProvider($actor); + if ($providerName === null) { + return SummariseResult::skipped('No enabled BYOK credential available.'); + } + + try { + $agent = new ContextSummariser($item, $body); + $response = $agent->prompt($agent->buildPrompt(), provider: $providerName); + $summary = trim((string) $response); + + if ($summary === '') { + return SummariseResult::skipped('Provider returned an empty summary.'); + } + + return SummariseResult::ready($summary); + } catch (Throwable $e) { + return SummariseResult::failed($e->getMessage()); + } finally { + Ai::forgetInstance($providerName); + $providers = (array) config('ai.providers', []); + unset($providers[$providerName]); + config(['ai.providers' => $providers]); + } + } + + private function resolveBody(ContextItem $item): string + { + $type = $item->type instanceof ContextItemType ? $item->type : ContextItemType::tryFrom((string) $item->type); + + return match ($type) { + ContextItemType::Text => trim((string) ($item->metadata['body'] ?? $item->description ?? '')), + ContextItemType::File => trim((string) ($item->metadata['extracted_text'] ?? $item->description ?? '')), + default => '', + }; + } + + private function bindUserProvider(User $user): ?string + { + $providers = UserAiCredential::supportedProviders(); + $preferred = $user->ai_provider; + if (is_string($preferred) && in_array($preferred, $providers, true)) { + $providers = array_values(array_unique([$preferred, ...$providers])); + } + + $credential = $user->aiCredentials() + ->where('enabled', true) + ->whereIn('provider', $providers) + ->orderByRaw( + 'case provider '. + implode(' ', array_map(fn ($p, $i) => "when ? then {$i}", $providers, array_keys($providers))). + ' end', + $providers, + ) + ->first(); + + if ($credential === null) { + return null; + } + + $providerName = 'context-summariser-'.$user->getKey().'-'.$credential->provider.'-'.bin2hex(random_bytes(4)); + $base = (array) config('ai.providers.'.$credential->provider, ['driver' => $credential->provider]); + $base['driver'] = $credential->provider; + $base['key'] = $credential->api_key; + + config(['ai.providers.'.$providerName => $base]); + Ai::forgetInstance($providerName); + + return $providerName; + } +} diff --git a/app/Services/Ai/SummariseResult.php b/app/Services/Ai/SummariseResult.php new file mode 100644 index 0000000..28f7f55 --- /dev/null +++ b/app/Services/Ai/SummariseResult.php @@ -0,0 +1,34 @@ + $project->getKey(), 'story_id' => $story?->getKey(), 'type' => ContextItemType::File, @@ -53,6 +54,24 @@ public function store(UploadedFile $file, Project $project, ?Story $story, User 'summary_status' => ContextItemSummaryStatus::Pending, 'created_by_id' => $actor->getKey(), ]); + + if ($this->shouldSummariseFile($file)) { + SummariseContextItemJob::dispatch($item->getKey()); + } + + return $item; + } + + private function shouldSummariseFile(UploadedFile $file): bool + { + $mime = (string) $file->getClientMimeType(); + if (! str_starts_with($mime, 'text/') && $mime !== 'application/pdf') { + return false; + } + + $threshold = (int) config('specify.context.assets.summary_threshold_chars', 2000); + + return $file->getSize() >= $threshold; } private function ensureStoryBelongsToProject(Project $project, ?Story $story): void diff --git a/app/Services/Context/ContextItemWriter.php b/app/Services/Context/ContextItemWriter.php index 4b946f7..e5beaf8 100644 --- a/app/Services/Context/ContextItemWriter.php +++ b/app/Services/Context/ContextItemWriter.php @@ -4,6 +4,7 @@ use App\Enums\ContextItemSummaryStatus; use App\Enums\ContextItemType; +use App\Jobs\SummariseContextItemJob; use App\Models\ContextItem; use App\Models\Project; use App\Models\Story; @@ -34,7 +35,7 @@ public function createProjectItem(Project $project, array $attributes, User $act { $this->ensureNonFileType($attributes['type'] ?? null); - return ContextItem::create([ + $item = ContextItem::create([ 'project_id' => $project->getKey(), 'story_id' => null, 'type' => $attributes['type'], @@ -44,6 +45,10 @@ public function createProjectItem(Project $project, array $attributes, User $act 'summary_status' => $this->initialSummaryStatus($attributes['type']), 'created_by_id' => $actor->getKey(), ]); + + $this->maybeDispatchSummarise($item); + + return $item; } /** @@ -81,6 +86,8 @@ public function createStoryItem(Story $story, array $attributes, User $actor): C $this->revisions->recordContentArtifactChanged($story); + $this->maybeDispatchSummarise($item); + return $item; }); } @@ -114,13 +121,26 @@ public function update(ContextItem $item, array $changes, User $actor): ContextI return $item; } + $bodyChanged = $item->isDirty('metadata') && $this->isTextItem($item); + if ($bodyChanged) { + $item->summary_status = ContextItemSummaryStatus::Pending; + $item->summary = null; + $item->summary_error = null; + } + $item->save(); if ($item->isStoryScoped() && $item->story) { $this->revisions->recordContentArtifactChanged($item->story); } - return $item->refresh(); + $fresh = $item->refresh(); + + if ($bodyChanged) { + $this->maybeDispatchSummarise($fresh); + } + + return $fresh; }); } @@ -175,6 +195,34 @@ private function initialSummaryStatus(mixed $type): ContextItemSummaryStatus : ContextItemSummaryStatus::Pending; } + private function maybeDispatchSummarise(ContextItem $item): void + { + if (! $this->isTextItem($item)) { + return; + } + + $body = (string) ($item->metadata['body'] ?? ''); + $threshold = (int) config('specify.context.assets.summary_threshold_chars', 2000); + + if (mb_strlen($body) < $threshold) { + // Short bodies stay raw — `bodyForContext()` handles them as-is. + $item->forceFill(['summary_status' => ContextItemSummaryStatus::Skipped->value])->save(); + + return; + } + + SummariseContextItemJob::dispatch($item->getKey()); + } + + private function isTextItem(ContextItem $item): bool + { + $type = $item->type instanceof ContextItemType + ? $item->type + : ContextItemType::tryFrom((string) $item->type); + + return $type === ContextItemType::Text; + } + private function deleteUnderlyingFile(ContextItem $item): void { if ($item->type !== ContextItemType::File) { diff --git a/prompts/context-summariser.md b/prompts/context-summariser.md new file mode 100644 index 0000000..48b756c --- /dev/null +++ b/prompts/context-summariser.md @@ -0,0 +1,18 @@ +You compress reference assets into compact briefs that an AI plan-generation +agent can later quote without burning prompt budget. + +You will be given a single asset's title, type, and raw body. Return a single +plain-text summary of the asset that: + +- Preserves the asset's purpose, key facts, named entities, requirements, + constraints, and any explicit must / must-not statements. +- Drops boilerplate, headings, navigational copy, and repeated examples. +- Stays under 1,000 characters. +- Uses neutral, declarative phrasing — no opinions, no questions, no + meta-commentary about the source. + +If the body is empty, contains only navigational chrome, or is unintelligible, +return a single line stating that no useful content was extractable. + +Output the summary text only. Do not wrap it in code fences or markdown +headings. diff --git a/tests/Feature/ContextItem/AssetUploaderTest.php b/tests/Feature/ContextItem/AssetUploaderTest.php index 49305e7..7485540 100644 --- a/tests/Feature/ContextItem/AssetUploaderTest.php +++ b/tests/Feature/ContextItem/AssetUploaderTest.php @@ -8,10 +8,12 @@ use App\Models\User; use App\Services\Context\AssetUploader; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Storage; beforeEach(function () { Storage::fake('private'); + Bus::fake(); }); test('store persists file and creates a project-scoped ContextItem', function () { diff --git a/tests/Feature/ContextItem/ContextItemWriterTest.php b/tests/Feature/ContextItem/ContextItemWriterTest.php index d1de841..a93bee2 100644 --- a/tests/Feature/ContextItem/ContextItemWriterTest.php +++ b/tests/Feature/ContextItem/ContextItemWriterTest.php @@ -10,9 +10,14 @@ use App\Models\User; use App\Services\Context\ContextItemWriter; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +beforeEach(function () { + Bus::fake(); +}); + function ciScene(): array { $project = Project::factory()->create(); @@ -39,7 +44,8 @@ function ciScene(): array expect($item->project_id)->toBe($project->id); expect($item->story_id)->toBeNull(); - expect($item->summary_status)->toBe(ContextItemSummaryStatus::Pending); + // Short text bodies skip summarisation; long bodies dispatch a job (slice 2 covers both). + expect($item->fresh()->summary_status)->toBe(ContextItemSummaryStatus::Skipped); expect($story->fresh()->revision)->toBe($beforeRev); expect($story->fresh()->status)->toBe(StoryStatus::Approved); }); diff --git a/tests/Feature/ContextItem/SummariseContextItemJobTest.php b/tests/Feature/ContextItem/SummariseContextItemJobTest.php new file mode 100644 index 0000000..3c04e2c --- /dev/null +++ b/tests/Feature/ContextItem/SummariseContextItemJobTest.php @@ -0,0 +1,88 @@ +create(); + $item = ContextItem::factory()->forText(str_repeat('lorem ipsum ', 200))->create([ + 'created_by_id' => $creator->id, + 'summary_status' => ContextItemSummaryStatus::Pending, + ]); + + (new SummariseContextItemJob($item->id))->handle(app(ContextCompressor::class)); + + $fresh = $item->fresh(); + expect($fresh->summary_status)->toBe(ContextItemSummaryStatus::Skipped); + expect($fresh->summary)->toBeNull(); +}); + +test('job marks item Skipped when creator is null', function () { + $item = ContextItem::factory()->forText(str_repeat('content ', 200))->create([ + 'created_by_id' => null, + 'summary_status' => ContextItemSummaryStatus::Pending, + ]); + + (new SummariseContextItemJob($item->id))->handle(app(ContextCompressor::class)); + + expect($item->fresh()->summary_status)->toBe(ContextItemSummaryStatus::Skipped); +}); + +test('job writes Ready summary when agent returns text and creator has creds', function () { + ContextSummariser::fake(fn () => 'short summary text'); + + $creator = User::factory()->create(['ai_provider' => UserAiCredential::PROVIDER_ANTHROPIC]); + UserAiCredential::create([ + 'user_id' => $creator->id, + 'provider' => UserAiCredential::PROVIDER_ANTHROPIC, + 'api_key' => 'sk-test', + 'enabled' => true, + ]); + + $item = ContextItem::factory()->forText(str_repeat('long body ', 300))->create([ + 'created_by_id' => $creator->id, + 'summary_status' => ContextItemSummaryStatus::Pending, + ]); + + (new SummariseContextItemJob($item->id))->handle(app(ContextCompressor::class)); + + $fresh = $item->fresh(); + expect($fresh->summary_status)->toBe(ContextItemSummaryStatus::Ready); + expect($fresh->summary)->toBe('short summary text'); +}); + +test('job marks Skipped when item has no compressible body', function () { + $creator = User::factory()->create(); + $item = ContextItem::factory()->forText('')->create([ + 'created_by_id' => $creator->id, + 'metadata' => ['body' => ''], + 'summary_status' => ContextItemSummaryStatus::Pending, + ]); + + (new SummariseContextItemJob($item->id))->handle(app(ContextCompressor::class)); + + expect($item->fresh()->summary_status)->toBe(ContextItemSummaryStatus::Skipped); +}); + +test('job is a no-op when ContextItem no longer exists', function () { + (new SummariseContextItemJob(999999))->handle(app(ContextCompressor::class)); + + expect(true)->toBeTrue(); +}); + +test('failed() callback marks the item Failed', function () { + $item = ContextItem::factory()->forText('hi')->create([ + 'summary_status' => ContextItemSummaryStatus::Pending, + ]); + + (new SummariseContextItemJob($item->id))->failed(new RuntimeException('worker died')); + + $fresh = $item->fresh(); + expect($fresh->summary_status)->toBe(ContextItemSummaryStatus::Failed); + expect($fresh->summary_error)->toBe('worker died'); +}); diff --git a/tests/Feature/ContextItem/WriterDispatchesSummariseTest.php b/tests/Feature/ContextItem/WriterDispatchesSummariseTest.php new file mode 100644 index 0000000..03d4dad --- /dev/null +++ b/tests/Feature/ContextItem/WriterDispatchesSummariseTest.php @@ -0,0 +1,89 @@ + 100]); +}); + +test('createProjectItem dispatches summarise when text body exceeds threshold', function () { + $project = Project::factory()->create(); + $actor = User::factory()->create(); + + $item = app(ContextItemWriter::class)->createProjectItem($project, [ + 'type' => ContextItemType::Text, + 'title' => 'Spec', + 'metadata' => ['body' => str_repeat('a', 500)], + ], $actor); + + Bus::assertDispatched(SummariseContextItemJob::class, fn ($job) => $job->contextItemId === $item->id); +}); + +test('createProjectItem skips dispatch and marks Skipped for short text', function () { + $project = Project::factory()->create(); + $actor = User::factory()->create(); + + $item = app(ContextItemWriter::class)->createProjectItem($project, [ + 'type' => ContextItemType::Text, + 'title' => 'Tiny', + 'metadata' => ['body' => 'short'], + ], $actor); + + Bus::assertNotDispatched(SummariseContextItemJob::class); + expect($item->fresh()->summary_status->value)->toBe('skipped'); +}); + +test('update on text item with new long body resets status and dispatches', function () { + $project = Project::factory()->create(); + $actor = User::factory()->create(); + $item = ContextItem::factory()->for($project)->forText('original')->create([ + 'summary' => 'old summary', + 'summary_status' => 'ready', + ]); + + app(ContextItemWriter::class)->update($item, [ + 'metadata' => ['body' => str_repeat('z', 500)], + ], $actor); + + Bus::assertDispatched(SummariseContextItemJob::class, fn ($job) => $job->contextItemId === $item->id); + + $fresh = $item->fresh(); + expect($fresh->summary)->toBeNull(); + expect($fresh->summary_status->value)->toBe('pending'); +}); + +test('update without body change does not dispatch', function () { + $project = Project::factory()->create(); + $actor = User::factory()->create(); + $item = ContextItem::factory()->for($project)->forText('original')->create([ + 'summary' => 'old summary', + 'summary_status' => 'ready', + ]); + + app(ContextItemWriter::class)->update($item, ['title' => 'New title only'], $actor); + + Bus::assertNotDispatched(SummariseContextItemJob::class); + expect($item->fresh()->summary_status->value)->toBe('ready'); +}); + +test('AssetUploader dispatches summarise for text-mime files over threshold', function () { + Storage::fake('private'); + $project = Project::factory()->create(); + $actor = User::factory()->create(); + + $file = UploadedFile::fake()->create('notes.txt', 4, 'text/plain'); + + $item = app(AssetUploader::class)->store($file, $project, null, $actor); + + Bus::assertDispatched(SummariseContextItemJob::class, fn ($job) => $job->contextItemId === $item->id); +}); From 3f6513b69df74e49eb2ef0b1b5a152a91d1e59ab Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Sun, 10 May 2026 10:36:45 +0200 Subject: [PATCH 2/3] Address Copilot review on slice 2: scope summarisation to text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AssetUploader no longer dispatches SummariseContextItemJob for files. ContextCompressor::resolveBody() for File-typed items reads metadata['extracted_text'] which the uploader doesn't populate, so the job would have marked the item Skipped on first run. File extraction (PDF/text) is a separate concern; when that lands the extractor will flip status to Pending and dispatch from there. - File items now land summary_status=Skipped at upload (was Pending), matching the actual behaviour of the compressor today. - SummariseContextItemJob now persists summary_error for both Skipped and Failed outcomes — skip reasons were silently dropped before. summary_status is the source of truth for outcome; the column is the audit trail. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Jobs/SummariseContextItemJob.php | 12 ++++++-- app/Services/Context/AssetUploader.php | 28 +++++-------------- .../Feature/ContextItem/AssetUploaderTest.php | 3 +- .../SummariseContextItemJobTest.php | 5 +++- .../WriterDispatchesSummariseTest.php | 8 ++++-- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/app/Jobs/SummariseContextItemJob.php b/app/Jobs/SummariseContextItemJob.php index 740eb11..8997e97 100644 --- a/app/Jobs/SummariseContextItemJob.php +++ b/app/Jobs/SummariseContextItemJob.php @@ -16,8 +16,11 @@ * * Missing creds are not failures — they collapse to `summary_status=skipped` * so plan generation can fall back to the truncated raw body. Real - * failures (provider errors, exceptions) record `summary_status=failed` - * with the error message in `summary_error`. + * failures (provider errors, exceptions) record `summary_status=failed`. + * + * `summary_error` carries the human-readable note in both cases (skip + * reason or failure cause). The status enum is the source of truth for + * "did this succeed"; the column is just the audit trail. */ class SummariseContextItemJob implements ShouldQueue { @@ -37,7 +40,10 @@ public function handle(ContextCompressor $compressor): void $item->forceFill([ 'summary_status' => $result->status->value, 'summary' => $result->status === ContextItemSummaryStatus::Ready ? $result->summary : null, - 'summary_error' => $result->status === ContextItemSummaryStatus::Failed ? $result->error : null, + // Persist the message for both Skipped (skip reason) and Failed + // (failure cause). Status is the source of truth for outcome; + // this column is the audit trail. + 'summary_error' => $result->status === ContextItemSummaryStatus::Ready ? null : $result->error, ])->save(); } diff --git a/app/Services/Context/AssetUploader.php b/app/Services/Context/AssetUploader.php index cac2ab2..d6979cb 100644 --- a/app/Services/Context/AssetUploader.php +++ b/app/Services/Context/AssetUploader.php @@ -4,7 +4,6 @@ use App\Enums\ContextItemSummaryStatus; use App\Enums\ContextItemType; -use App\Jobs\SummariseContextItemJob; use App\Models\ContextItem; use App\Models\Project; use App\Models\Story; @@ -35,7 +34,7 @@ public function store(UploadedFile $file, Project $project, ?Story $story, User throw new \RuntimeException('Failed to store uploaded asset.'); } - $item = ContextItem::create([ + return ContextItem::create([ 'project_id' => $project->getKey(), 'story_id' => $story?->getKey(), 'type' => ContextItemType::File, @@ -51,27 +50,14 @@ public function store(UploadedFile $file, Project $project, ?Story $story, User 'mime' => $file->getMimeType() ?: 'application/octet-stream', 'size' => $file->getSize(), ], - 'summary_status' => ContextItemSummaryStatus::Pending, + // File items stay Skipped: there is no extraction pipeline yet, + // so ContextCompressor would have no body to compress and + // would mark Skipped on the first job run anyway. When the + // PDF/text extractor lands, this can flip to Pending and the + // extractor will dispatch SummariseContextItemJob from there. + 'summary_status' => ContextItemSummaryStatus::Skipped, 'created_by_id' => $actor->getKey(), ]); - - if ($this->shouldSummariseFile($file)) { - SummariseContextItemJob::dispatch($item->getKey()); - } - - return $item; - } - - private function shouldSummariseFile(UploadedFile $file): bool - { - $mime = (string) $file->getClientMimeType(); - if (! str_starts_with($mime, 'text/') && $mime !== 'application/pdf') { - return false; - } - - $threshold = (int) config('specify.context.assets.summary_threshold_chars', 2000); - - return $file->getSize() >= $threshold; } private function ensureStoryBelongsToProject(Project $project, ?Story $story): void diff --git a/tests/Feature/ContextItem/AssetUploaderTest.php b/tests/Feature/ContextItem/AssetUploaderTest.php index 7485540..ccf33e9 100644 --- a/tests/Feature/ContextItem/AssetUploaderTest.php +++ b/tests/Feature/ContextItem/AssetUploaderTest.php @@ -26,7 +26,8 @@ expect($item->type)->toBe(ContextItemType::File); expect($item->project_id)->toBe($project->id); expect($item->story_id)->toBeNull(); - expect($item->summary_status)->toBe(ContextItemSummaryStatus::Pending); + // Slice 2: file uploads land Skipped — extraction pipeline is future work. + expect($item->summary_status)->toBe(ContextItemSummaryStatus::Skipped); expect($item->created_by_id)->toBe($actor->id); expect($item->title)->toBe('Spec Notes.pdf'); expect($item->metadata['original_name'])->toBe('Spec Notes.pdf'); diff --git a/tests/Feature/ContextItem/SummariseContextItemJobTest.php b/tests/Feature/ContextItem/SummariseContextItemJobTest.php index 3c04e2c..c9af88c 100644 --- a/tests/Feature/ContextItem/SummariseContextItemJobTest.php +++ b/tests/Feature/ContextItem/SummariseContextItemJobTest.php @@ -8,7 +8,7 @@ use App\Models\UserAiCredential; use App\Services\Ai\ContextCompressor; -test('job marks item Skipped when creator has no BYOK creds', function () { +test('job marks item Skipped when creator has no BYOK creds and records the reason', function () { $creator = User::factory()->create(); $item = ContextItem::factory()->forText(str_repeat('lorem ipsum ', 200))->create([ 'created_by_id' => $creator->id, @@ -20,6 +20,9 @@ $fresh = $item->fresh(); expect($fresh->summary_status)->toBe(ContextItemSummaryStatus::Skipped); expect($fresh->summary)->toBeNull(); + // Skip reasons are persisted in summary_error too — the column is the + // audit trail, not strictly a failure log. + expect($fresh->summary_error)->not->toBeNull(); }); test('job marks item Skipped when creator is null', function () { diff --git a/tests/Feature/ContextItem/WriterDispatchesSummariseTest.php b/tests/Feature/ContextItem/WriterDispatchesSummariseTest.php index 03d4dad..70a4cb0 100644 --- a/tests/Feature/ContextItem/WriterDispatchesSummariseTest.php +++ b/tests/Feature/ContextItem/WriterDispatchesSummariseTest.php @@ -76,7 +76,7 @@ expect($item->fresh()->summary_status->value)->toBe('ready'); }); -test('AssetUploader dispatches summarise for text-mime files over threshold', function () { +test('AssetUploader does not dispatch summarise — file extraction is future work', function () { Storage::fake('private'); $project = Project::factory()->create(); $actor = User::factory()->create(); @@ -85,5 +85,9 @@ $item = app(AssetUploader::class)->store($file, $project, null, $actor); - Bus::assertDispatched(SummariseContextItemJob::class, fn ($job) => $job->contextItemId === $item->id); + // No extraction pipeline yet, so files land Skipped and the job stays + // off the queue. The future extractor will flip status to Pending and + // dispatch from there. + Bus::assertNotDispatched(SummariseContextItemJob::class); + expect($item->summary_status->value)->toBe('skipped'); }); From 8debda27cd0f684714ecb7197e28a49a05176e61 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Sun, 10 May 2026 10:50:39 +0200 Subject: [PATCH 3/3] Gap analysis: AssetUploader docstring drifted from slice-2 behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 changed file uploads to land at summary_status=skipped (no extraction pipeline yet) but the class-level docblock still claimed "summary_status=pending so that worker picks them up later." Update the docstring to match — and document the future-extractor handoff. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Services/Context/AssetUploader.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/Services/Context/AssetUploader.php b/app/Services/Context/AssetUploader.php index d6979cb..519dc43 100644 --- a/app/Services/Context/AssetUploader.php +++ b/app/Services/Context/AssetUploader.php @@ -15,9 +15,11 @@ /** * Persists an uploaded file to the configured `private` disk and creates a - * matching ContextItem row. Story summarisation (lazy) is owned by the - * companion job in Slice 2 — uploads here always land in - * `summary_status=pending` so that worker picks them up later. + * matching ContextItem row. Files land in `summary_status=skipped` + * because there is no extraction pipeline yet — `ContextCompressor` would + * have nothing to feed the summariser. When the extractor lands, it will + * flip the row to `Pending` and dispatch `SummariseContextItemJob` from + * there. */ class AssetUploader {