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

namespace App\Enums;

/**
* Lifecycle of the lazy AI summary attached to a ContextItem.
*
* `Pending` is the initial state; `Ready` means a summary is stored;
* `Skipped` means summarisation was bypassed (e.g. body short enough or
* no BYOK creds); `Failed` carries the error in `summary_error`.
*/
enum ContextItemSummaryStatus: string
{
case Pending = 'pending';
case Ready = 'ready';
case Skipped = 'skipped';
case Failed = 'failed';
}
10 changes: 10 additions & 0 deletions app/Enums/ContextItemType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace App\Enums;

enum ContextItemType: string
{
case File = 'file';
case Link = 'link';
case Text = 'text';
}
117 changes: 117 additions & 0 deletions app/Models/ContextItem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

namespace App\Models;

use App\Enums\ContextItemSummaryStatus;
use App\Enums\ContextItemType;
use Database\Factories\ContextItemFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;

/**
* Project- or Story-scoped reference asset that feeds the AI context brief.
*
* `project_id` is always set; `story_id` is set only for story-scoped items
* that auto-include into that Story. Selection of project-scoped items into
* a Story flows through the `context_item_story` pivot.
*/
#[Fillable([
'project_id',
'story_id',
'type',
'title',
'description',
'metadata',
'summary',
'summary_status',
'summary_error',
'created_by_id',
])]
class ContextItem extends Model
{
/** @use HasFactory<ContextItemFactory> */
use HasFactory, SoftDeletes;

/**
* Soft cap for raw text returned by `bodyForContext()` when no summary
* is available. Roughly 4 KB of UTF-8 — enough to keep plans usable
* without dominating the prompt budget.
*/
public const BODY_FALLBACK_CHARS = 4000;

protected function casts(): array
{
return [
'type' => ContextItemType::class,
'summary_status' => ContextItemSummaryStatus::class,
'metadata' => 'array',
];
}

public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}

public function story(): BelongsTo
{
return $this->belongsTo(Story::class);
}

public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_id');
}

public function includedInStories(): BelongsToMany
{
return $this->belongsToMany(Story::class, 'context_item_story')
->withPivot(['included_at', 'included_by_id']);
}

public function isProjectScoped(): bool
{
return $this->story_id === null;
}

public function isStoryScoped(): bool
{
return $this->story_id !== null;
}

/**
* The text rendered into the AI prompt. Prefers a ready summary; falls
* back to the truncated raw text body so plans still generate when
* summarisation was skipped or the item never went through compression.
*/
public function bodyForContext(): string
{
if ($this->summary_status === ContextItemSummaryStatus::Ready && filled($this->summary)) {
return (string) $this->summary;
}

$raw = $this->rawBody();
if ($raw === '') {
return '';
}

return Str::limit($raw, self::BODY_FALLBACK_CHARS, '…');
}

private function rawBody(): string
{
$type = $this->type instanceof ContextItemType ? $this->type : ContextItemType::tryFrom((string) $this->type);

return match ($type) {
ContextItemType::Text => (string) ($this->metadata['body'] ?? $this->description ?? ''),
ContextItemType::Link => (string) ($this->metadata['url'] ?? ''),
ContextItemType::File => (string) ($this->metadata['original_name'] ?? $this->metadata['path'] ?? ''),
null => (string) ($this->description ?? ''),
};
}
}
5 changes: 5 additions & 0 deletions app/Models/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ public function features(): HasMany
return $this->hasMany(Feature::class);
}

public function contextItems(): HasMany
{
return $this->hasMany(ContextItem::class);
}

public function stories(): HasManyThrough
{
return $this->hasManyThrough(Story::class, Feature::class);
Expand Down
42 changes: 42 additions & 0 deletions app/Models/Story.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use App\Services\Stories\StoryRunProjection;
use Database\Factories\StoryFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
Expand Down Expand Up @@ -217,6 +218,47 @@ public function scenarios(): HasMany
return $this->hasMany(Scenario::class)->orderBy('position');
}

/**
* Story-scoped ContextItems (those whose `story_id` matches this story).
* `ContextItemWriter::createStoryItem` auto-attaches them to this
* Story's selection, and `ContextItemSelector` refuses to detach them
* — toggling story-scoped items off is not a picker concern. They
* leave the selection only when deleted via `ContextItemWriter::delete`.
*/
public function ownedContextItems(): HasMany
{
return $this->hasMany(ContextItem::class);
}

/**
* Items currently selected as AI context for this Story via the pivot.
* The set is the union of explicit selections from project-scoped items
* and the auto-included story-scoped items.
*/
public function includedContextItems(): BelongsToMany
{
return $this->belongsToMany(ContextItem::class, 'context_item_story')
->withPivot(['included_at', 'included_by_id']);
}

/**
* The picker's source set: every ContextItem that *could* be selected
* for this Story — i.e. project-scoped items in the same Project plus
* this Story's own story-scoped items.
*
* @return Builder<ContextItem>
*/
public function availableContextItems(): Builder
{
$projectId = $this->feature?->project_id;

return ContextItem::query()
->where('project_id', $projectId)
->where(function ($q): void {
$q->whereNull('story_id')->orWhere('story_id', $this->getKey());
});
}

public function dependencies(): BelongsToMany
{
return $this->belongsToMany(self::class, 'story_dependencies', 'story_id', 'depends_on_story_id');
Expand Down
109 changes: 109 additions & 0 deletions app/Services/Context/AssetUploader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

namespace App\Services\Context;

use App\Enums\ContextItemSummaryStatus;
use App\Enums\ContextItemType;
use App\Models\ContextItem;
use App\Models\Project;
use App\Models\Story;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use InvalidArgumentException;

/**
* 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.
*/
class AssetUploader
{
public function store(UploadedFile $file, Project $project, ?Story $story, User $actor): ContextItem
{
$this->ensureStoryBelongsToProject($project, $story);
$this->validate($file);

$disk = $this->disk();
$directory = 'context/'.Str::lower((string) Str::ulid());
$storedPath = $file->storeAs($directory, $this->safeFilename($file), ['disk' => $disk]);

if ($storedPath === false) {
throw new \RuntimeException('Failed to store uploaded asset.');
}

return ContextItem::create([
'project_id' => $project->getKey(),
'story_id' => $story?->getKey(),
'type' => ContextItemType::File,
'title' => $file->getClientOriginalName() ?: basename($storedPath),
'description' => null,
'metadata' => [
'disk' => $disk,
'path' => $storedPath,
'original_name' => $file->getClientOriginalName(),
// Server-detected MIME — getClientMimeType() is user-controlled and
// trivial to spoof. The detected value is what we record and what
// later checks should rely on.
'mime' => $file->getMimeType() ?: 'application/octet-stream',
'size' => $file->getSize(),
],
'summary_status' => ContextItemSummaryStatus::Pending,
'created_by_id' => $actor->getKey(),
]);
}

private function ensureStoryBelongsToProject(Project $project, ?Story $story): void
{
if ($story === null) {
return;
}

$storyProjectId = $story->feature?->project_id;
if ((int) $storyProjectId !== (int) $project->getKey()) {
throw new InvalidArgumentException('Story does not belong to the given project.');
}
}

private function validate(UploadedFile $file): void
{
$maxKb = (int) config('specify.context.assets.max_file_kb', 10240);
$sizeKb = (int) ceil($file->getSize() / 1024);
if ($maxKb > 0 && $sizeKb > $maxKb) {
throw new InvalidArgumentException("Uploaded file exceeds the {$maxKb} KB limit.");
}

$allowed = (array) config('specify.context.assets.allowed_mimes', []);
$detected = $file->getMimeType() ?: 'application/octet-stream';
if ($allowed !== [] && ! in_array($detected, $allowed, true)) {
throw new InvalidArgumentException("MIME type {$detected} is not allowed.");
}
Comment thread
datashaman marked this conversation as resolved.
}

private function safeFilename(UploadedFile $file): string
{
$original = $file->getClientOriginalName() ?: 'upload';
$base = Str::of(pathinfo($original, PATHINFO_FILENAME))->slug()->limit(80, '');
$ext = strtolower((string) $file->getClientOriginalExtension());

$name = $base->isEmpty() ? 'upload' : (string) $base;

return $ext === '' ? $name : "{$name}.{$ext}";
}

private function disk(): string
{
$disk = (string) config('specify.context.assets.disk', 'private');

if (! array_key_exists($disk, config('filesystems.disks', []))) {
throw new \RuntimeException("Configured assets disk '{$disk}' is not defined.");
}

// Touch the disk so misconfiguration surfaces here (not in storeAs).
Storage::disk($disk);

return $disk;
}
}
Loading