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
49 changes: 49 additions & 0 deletions app/Ai/Agents/ContextSummariser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace App\Ai\Agents;

use App\Models\ContextItem;
use App\Services\Prompts\PromptLoader;
use Laravel\Ai\Attributes\MaxTokens;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Attributes\UseCheapestModel;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;

/**
* Compresses a single ContextItem body into a ≤1 KB summary the
* plan-generation agent can quote without dominating its prompt budget.
*
* Output is plain text (no structured schema). Trigger via
* `App\Services\Ai\ContextCompressor`, which handles BYOK resolution,
* skipped/failed status writes, and "no creds" fallbacks.
*/
#[Provider(Lab::Anthropic)]
#[UseCheapestModel]
#[MaxTokens(1024)]
class ContextSummariser implements Agent
{
use Promptable;

public function __construct(public ContextItem $item, public string $body) {}

public function instructions(): string
{
return app(PromptLoader::class)->load('context-summariser');
}

public function buildPrompt(): string
{
$title = $this->item->title;
$type = $this->item->type?->value ?? 'unknown';

return <<<PROMPT
Title: {$title}
Type: {$type}

Body:
{$this->body}
PROMPT;
}
}
67 changes: 67 additions & 0 deletions app/Jobs/SummariseContextItemJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace App\Jobs;

use App\Enums\ContextItemSummaryStatus;
use App\Models\ContextItem;
use App\Services\Ai\ContextCompressor;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
* Compresses a ContextItem's body via the BYOK-resolved summariser agent
* and writes the outcome back to the row.
*
* 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`.
*
* `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
{
use Queueable;

public function __construct(public int $contextItemId) {}

public function handle(ContextCompressor $compressor): void
{
$item = ContextItem::query()->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,
// 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();
}

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();
}
}
103 changes: 103 additions & 0 deletions app/Services/Ai/ContextCompressor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace App\Services\Ai;

use App\Ai\Agents\ContextSummariser;
use App\Enums\ContextItemType;
use App\Models\ContextItem;
use App\Models\User;
use App\Models\UserAiCredential;
use Laravel\Ai\Ai;
use Throwable;

/**
* Lazily summarises a ContextItem using a user's BYOK credentials.
*
* Returns a `SummariseResult` value object — the caller (typically
* `SummariseContextItemJob`) writes the outcome back to the row. Missing
* creds are not an error; they yield `Skipped`, and `bodyForContext()`
* falls back to the truncated raw body so plan generation still works.
*/
class ContextCompressor
{
public function summarise(ContextItem $item, ?User $actor): SummariseResult
{
$body = $this->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;
}
}
34 changes: 34 additions & 0 deletions app/Services/Ai/SummariseResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace App\Services\Ai;

use App\Enums\ContextItemSummaryStatus;

/**
* Outcome of a single ContextCompressor::summarise() call. Pure value
* object — the job is responsible for writing the result back to the
* ContextItem row.
*/
class SummariseResult
{
public function __construct(
public ContextItemSummaryStatus $status,
public ?string $summary = null,
public ?string $error = null,
) {}

public static function ready(string $summary): self
{
return new self(ContextItemSummaryStatus::Ready, $summary);
}

public static function skipped(?string $reason = null): self
{
return new self(ContextItemSummaryStatus::Skipped, error: $reason);
}

public static function failed(string $error): self
{
return new self(ContextItemSummaryStatus::Failed, error: $error);
}
}
15 changes: 11 additions & 4 deletions app/Services/Context/AssetUploader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -50,7 +52,12 @@ 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(),
]);
}
Expand Down
52 changes: 50 additions & 2 deletions app/Services/Context/ContextItemWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'],
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -81,6 +86,8 @@ public function createStoryItem(Story $story, array $attributes, User $actor): C

$this->revisions->recordContentArtifactChanged($story);

$this->maybeDispatchSummarise($item);

return $item;
});
}
Expand Down Expand Up @@ -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;
});
}

Expand Down Expand Up @@ -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) {
Expand Down
Loading