-
Notifications
You must be signed in to change notification settings - Fork 0
Project & Story context assets — slice 1: schema, models, uploader, writers #110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 ?? ''), | ||
| }; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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."); | ||
| } | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.