diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2dcc2..c135a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,58 @@ One-click content repurposing to 8 formats with AI-powered tone preservation and --- ## [Unreleased] +### Added + +**AI Pipeline Templates & Preset Library** ([Issue #36](https://github.com/byte5digital/numen/issues/36)) + +Reusable AI pipeline templates for accelerated content creation workflows, featuring 8 built-in templates, community library, space-scoped templates, one-click install wizard, template versioning, and plugin registration hooks. + +**Features:** +- **8 built-in templates:** Blog Post Pipeline, Social Media Campaign, Product Description, Email Newsletter, Press Release, Landing Page, Technical Documentation, Video Script +- **Template library API:** Discover, rate, and install templates with metadata +- **Space-scoped templates:** Custom templates per content space with full RBAC support +- **Install wizard:** Auto-configures personas, stages, and input variables from template schema +- **Template versioning:** Track changes, publish/unpublish versions, rollback support +- **Template packs:** Plugin system for registering template collections +- **Community ratings:** Rate and provide feedback on templates +- **Metadata support:** Categories, icons, author info, and schema versioning +- **Security:** Space-scoped installs, RBAC permission gates, audit logging + +**Endpoints:** +- `GET /api/v1/spaces/{space}/pipeline-templates` — List templates (paginated) +- `POST /api/v1/spaces/{space}/pipeline-templates` — Create custom template +- `GET /api/v1/spaces/{space}/pipeline-templates/{template}` — Get template details +- `PATCH /api/v1/spaces/{space}/pipeline-templates/{template}` — Update template +- `DELETE /api/v1/spaces/{space}/pipeline-templates/{template}` — Delete template +- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/publish` — Publish template +- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/unpublish` — Unpublish template +- `GET /api/v1/spaces/{space}/pipeline-templates/{template}/versions` — List versions +- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/versions` — Create version +- `GET /api/v1/spaces/{space}/pipeline-templates/{template}/versions/{version}` — Get version +- `POST /api/v1/spaces/{space}/pipeline-templates/installs/{version}` — Install template (rate-limited 5/min) +- `PATCH /api/v1/spaces/{space}/pipeline-templates/installs/{install}` — Update install +- `DELETE /api/v1/spaces/{space}/pipeline-templates/installs/{install}` — Remove install +- `GET /api/v1/spaces/{space}/pipeline-templates/{template}/ratings` — List ratings +- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/ratings` — Rate template + +**Plugin Hooks:** +- `registerTemplateCategory(array $category)` — Register custom template categories +- `registerTemplatePack(array $pack)` — Register template collections from plugins + +**Models:** +- `PipelineTemplate` — Template metadata (name, slug, category, icon, author) +- `PipelineTemplateVersion` — Versioned template definitions with JSON schema +- `PipelineTemplateInstall` — Track template usage per space +- `PipelineTemplateRating` — Community feedback (1-5 stars) + +**New environment variables:** +- `TEMPLATE_LIBRARY_ENABLED=true` +- `TEMPLATE_INSTALL_RATE_LIMIT=5` (per minute) + +See [docs/pipeline-templates.md](docs/pipeline-templates.md) for complete documentation. + + + ### Added - Webhooks admin UI — manage webhook endpoints, event subscriptions, delivery logs, and secret rotation directly from the admin panel (Settings → Webhooks) diff --git a/README.md b/README.md index da1ced5..6780c89 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,28 @@ Manage webhook endpoints and event subscriptions directly from the admin panel ( - See [docs/graphql-api.md](docs/graphql-api.md) for the full guide -### Plugin & Extension System -First-class plugin architecture. Extend pipelines, register custom LLM providers, add admin UI, and react to content events — all from a self-contained plugin package. +### AI Pipeline Templates & Preset Library +**New in v0.10.0.** Reusable AI pipeline templates for accelerated content creation workflows. + +**Features:** +- **8 built-in templates:** Blog Post Pipeline, Social Media Campaign, Product Description, Email Newsletter, Press Release, Landing Page, Technical Documentation, Video Script +- **Template library API:** Browse, rate, and install templates from community library +- **Space-scoped templates:** Custom templates per content space with RBAC +- **One-click install wizard:** Auto-configures personas, stages, and variables from template schema +- **Template versioning:** Version management with changelog and rollback support +- **Template packs:** Plugin-registered template collections with metadata +- **Plugin hooks:** `registerTemplateCategory()` and `registerTemplatePack()` for extending the library +- **Template ratings:** Community feedback and quality metrics + +**Endpoints:** +- `GET /api/v1/spaces/{space}/pipeline-templates` — List templates +- `POST /api/v1/spaces/{space}/pipeline-templates` — Create custom template +- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/publish` — Publish template +- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/versions` — Create new version +- `POST /api/v1/spaces/{space}/pipeline-templates/installs/{version}` — Install template +- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/ratings` — Rate template + +See [docs/pipeline-templates.md](docs/pipeline-templates.md) for the complete feature guide. ### Plugin & Extension System First-class plugin architecture. Extend pipelines, register custom LLM providers, add admin UI, and react to content events — all from a self-contained plugin package. diff --git a/app/Http/Controllers/Admin/TemplateLibraryController.php b/app/Http/Controllers/Admin/TemplateLibraryController.php new file mode 100644 index 0000000..047cae2 --- /dev/null +++ b/app/Http/Controllers/Admin/TemplateLibraryController.php @@ -0,0 +1,68 @@ +user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/Library', [ + 'spaceId' => ($space !== null ? $space->id : ''), + ]); + } + + public function create(Request $request) + { + /** @var \App\Models\User $user */ + $user = $request->user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/Editor', [ + 'spaceId' => ($space !== null ? $space->id : ''), + ]); + } + + public function edit(Request $request, string $templateId) + { + /** @var \App\Models\User $user */ + $user = $request->user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/Editor', [ + 'spaceId' => ($space !== null ? $space->id : ''), + 'templateId' => $templateId, + ]); + } + + public function marketplace(Request $request) + { + /** @var \App\Models\User $user */ + $user = $request->user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/Marketplace', [ + 'spaceId' => ($space !== null ? $space->id : ''), + ]); + } + + public function install(Request $request) + { + /** @var \App\Models\User $user */ + $user = $request->user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/InstallWizard', [ + 'spaceId' => ($space !== null ? $space->id : ''), + 'templateId' => $request->query('template'), + ]); + } +} diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateController.php new file mode 100644 index 0000000..9c5df60 --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateController.php @@ -0,0 +1,96 @@ +where('space_id', $space->id) + ->latest() + ->get(); + + $marketplace = PipelineTemplate::with('latestVersion') + ->whereNull('space_id') + ->where('is_published', true) + ->latest() + ->get(); + + return PipelineTemplateResource::collection($spaceTemplates->merge($marketplace)); + } + + public function show(Space $space, PipelineTemplate $template): PipelineTemplateResource + { + abort_if($template->space_id !== null && $template->space_id !== $space->id, 403); + + $template->load('latestVersion', 'versions'); + + return new PipelineTemplateResource($template); + } + + public function store(Space $space, StorePipelineTemplateRequest $request): JsonResponse + { + $data = $request->validated(); + $template = $this->service->create($space, $data); + + if (! empty($data['definition'])) { + $this->service->createVersion($template, $data['definition'], $data['version'] ?? '1.0.0', $data['changelog'] ?? null); + } + + $template->load('latestVersion'); + + return (new PipelineTemplateResource($template))->response()->setStatusCode(201); + } + + public function update(Space $space, PipelineTemplate $template, UpdatePipelineTemplateRequest $request): PipelineTemplateResource + { + abort_if($template->space_id !== null && $template->space_id !== $space->id, 403); + + $template = $this->service->update($template, $request->validated()); + $template->load('latestVersion'); + + return new PipelineTemplateResource($template); + } + + public function destroy(Space $space, PipelineTemplate $template): JsonResponse + { + abort_if($template->space_id !== null && $template->space_id !== $space->id, 403); + + $this->service->delete($template); + + return response()->json(null, 204); + } + + public function publish(Space $space, PipelineTemplate $template): PipelineTemplateResource + { + abort_if($template->space_id !== null && $template->space_id !== $space->id, 403); + + $this->service->publish($template); + + return new PipelineTemplateResource($template->refresh()); + } + + public function unpublish(Space $space, PipelineTemplate $template): PipelineTemplateResource + { + abort_if($template->space_id !== null && $template->space_id !== $space->id, 403); + + $this->service->unpublish($template); + + return new PipelineTemplateResource($template->refresh()); + } +} diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateInstallController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateInstallController.php new file mode 100644 index 0000000..406168b --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateInstallController.php @@ -0,0 +1,54 @@ +validated(); + $install = $this->installService->install($version, $space, $data['variable_values'] ?? [], $data['config_overrides'] ?? []); + $install->load('template', 'templateVersion'); + + return (new PipelineTemplateInstallResource($install))->response()->setStatusCode(201); + } + + public function destroy(PipelineTemplateInstall $install, Space $space): JsonResponse + { + abort_if($install->space_id !== $space->id, 403); + + $this->installService->uninstall($install); + + return response()->json(null, 204); + } + + public function update(PipelineTemplateInstall $install, Space $space, InstallTemplateRequest $request): PipelineTemplateInstallResource + { + abort_if($install->space_id !== $space->id, 403); + + $install->loadMissing('templateVersion.template'); + /** @var PipelineTemplateVersion $currentVersion */ + $currentVersion = $install->getRelation('templateVersion'); + /** @var \App\Models\PipelineTemplate $tmpl */ + $tmpl = $currentVersion->getRelation('template'); + /** @var PipelineTemplateVersion $newVersion */ + $newVersion = $tmpl->versions()->where('is_latest', true)->firstOrFail(); + $updatedInstall = $this->installService->update($install, $newVersion); + $updatedInstall->load('template', 'templateVersion'); + + return new PipelineTemplateInstallResource($updatedInstall); + } +} diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateRatingController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateRatingController.php new file mode 100644 index 0000000..47f9b98 --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateRatingController.php @@ -0,0 +1,45 @@ +ratings()->with('user')->latest()->get(); + $average = $ratings->avg('rating'); + + return response()->json([ + 'data' => $ratings->map(fn (PipelineTemplateRating $r) => [ + 'id' => $r->id, + 'rating' => $r->rating, + 'review' => $r->review, + 'user' => $r->user ? ['id' => $r->user->id, 'name' => $r->user->name] : null, + 'created_at' => $r->created_at->toIso8601String(), + ]), + 'meta' => [ + 'average_rating' => $average ? round((float) $average, 2) : null, + 'total' => $ratings->count(), + ], + ]); + } + + public function store(Space $space, PipelineTemplate $template, RateTemplateRequest $request): JsonResponse + { + $rating = PipelineTemplateRating::updateOrCreate( + ['template_id' => $template->id, 'user_id' => $request->user()?->id], + ['rating' => $request->integer('rating'), 'review' => $request->string('review')->value() ?: null], + ); + + return response()->json([ + 'data' => ['id' => $rating->id, 'rating' => $rating->rating, 'review' => $rating->review, 'created_at' => $rating->created_at->toIso8601String()], + ], 201); + } +} diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateVersionController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateVersionController.php new file mode 100644 index 0000000..1478054 --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateVersionController.php @@ -0,0 +1,38 @@ +versions()->latest()->get()); + } + + public function store(Space $space, PipelineTemplate $template, CreateVersionRequest $request): JsonResponse + { + $data = $request->validated(); + $version = $this->service->createVersion($template, $data['definition'], $data['version'], $data['changelog'] ?? null); + + return (new PipelineTemplateVersionResource($version))->response()->setStatusCode(201); + } + + public function show(Space $space, PipelineTemplate $template, PipelineTemplateVersion $version): PipelineTemplateVersionResource + { + return new PipelineTemplateVersionResource($version); + } +} diff --git a/app/Http/Requests/Templates/CreateVersionRequest.php b/app/Http/Requests/Templates/CreateVersionRequest.php new file mode 100644 index 0000000..4198004 --- /dev/null +++ b/app/Http/Requests/Templates/CreateVersionRequest.php @@ -0,0 +1,27 @@ +user() !== null; + } + + /** @return array> */ + public function rules(): array + { + return [ + 'version' => ['required', 'string', 'max:50'], + 'definition' => ['required', 'array'], + 'definition.schema_version' => ['required', 'string'], + 'definition.stages' => ['required', 'array', 'min:1'], + 'definition.settings' => ['nullable', 'array'], + 'definition.personas' => ['nullable', 'array'], + 'changelog' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Templates/InstallTemplateRequest.php b/app/Http/Requests/Templates/InstallTemplateRequest.php new file mode 100644 index 0000000..8f19b80 --- /dev/null +++ b/app/Http/Requests/Templates/InstallTemplateRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + /** @return array> */ + public function rules(): array + { + return [ + 'variable_values' => ['nullable', 'array'], + 'config_overrides' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/Templates/RateTemplateRequest.php b/app/Http/Requests/Templates/RateTemplateRequest.php new file mode 100644 index 0000000..fb3a001 --- /dev/null +++ b/app/Http/Requests/Templates/RateTemplateRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + /** @return array> */ + public function rules(): array + { + return [ + 'rating' => ['required', 'integer', 'min:1', 'max:5'], + 'review' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/Templates/StorePipelineTemplateRequest.php b/app/Http/Requests/Templates/StorePipelineTemplateRequest.php new file mode 100644 index 0000000..9b0ee47 --- /dev/null +++ b/app/Http/Requests/Templates/StorePipelineTemplateRequest.php @@ -0,0 +1,34 @@ +user() !== null; + } + + /** @return array> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'category' => ['nullable', 'string', 'max:100'], + 'icon' => ['nullable', 'string', 'max:50'], + 'author_name' => ['nullable', 'string', 'max:255'], + 'author_url' => ['nullable', 'url', 'max:500'], + 'definition' => ['required', 'array'], + 'definition.schema_version' => ['required', 'string'], + 'definition.stages' => ['required', 'array', 'min:1'], + 'definition.settings' => ['nullable', 'array'], + 'definition.personas' => ['nullable', 'array'], + 'version' => ['nullable', 'string', 'max:50'], + 'changelog' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Templates/UpdatePipelineTemplateRequest.php b/app/Http/Requests/Templates/UpdatePipelineTemplateRequest.php new file mode 100644 index 0000000..3b69b74 --- /dev/null +++ b/app/Http/Requests/Templates/UpdatePipelineTemplateRequest.php @@ -0,0 +1,26 @@ +user() !== null; + } + + /** @return array> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'category' => ['nullable', 'string', 'max:100'], + 'icon' => ['nullable', 'string', 'max:50'], + 'author_name' => ['nullable', 'string', 'max:255'], + 'author_url' => ['nullable', 'url', 'max:500'], + ]; + } +} diff --git a/app/Http/Resources/PipelineTemplateInstallResource.php b/app/Http/Resources/PipelineTemplateInstallResource.php new file mode 100644 index 0000000..400951b --- /dev/null +++ b/app/Http/Resources/PipelineTemplateInstallResource.php @@ -0,0 +1,28 @@ + $this->id, + 'template_id' => $this->template_id, + 'version_id' => $this->version_id, + 'space_id' => $this->space_id, + 'pipeline_id' => $this->pipeline_id, + 'installed_at' => $this->installed_at->toIso8601String(), + 'config_overrides' => $this->config_overrides, + 'template' => $this->whenLoaded('template', fn () => new PipelineTemplateResource($this->template)), + 'version' => $this->whenLoaded('templateVersion', fn () => new PipelineTemplateVersionResource($this->templateVersion)), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/PipelineTemplateResource.php b/app/Http/Resources/PipelineTemplateResource.php new file mode 100644 index 0000000..fdb80fd --- /dev/null +++ b/app/Http/Resources/PipelineTemplateResource.php @@ -0,0 +1,33 @@ + $this->id, + 'space_id' => $this->space_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'description' => $this->description, + 'category' => $this->category, + 'icon' => $this->icon, + 'schema_version' => $this->schema_version, + 'is_published' => $this->is_published, + 'author_name' => $this->author_name, + 'author_url' => $this->author_url, + 'downloads_count' => $this->downloads_count, + 'latest_version' => $this->whenLoaded('latestVersion', fn () => new PipelineTemplateVersionResource($this->latestVersion)), + 'versions_count' => $this->whenLoaded('versions', fn () => $this->versions->count()), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/PipelineTemplateVersionResource.php b/app/Http/Resources/PipelineTemplateVersionResource.php new file mode 100644 index 0000000..90add71 --- /dev/null +++ b/app/Http/Resources/PipelineTemplateVersionResource.php @@ -0,0 +1,29 @@ + $this->id, + 'template_id' => $this->template_id, + 'version' => $this->version, + 'changelog' => $this->changelog, + 'is_latest' => $this->is_latest, + 'published_at' => $this->published_at?->toIso8601String(), + 'definition' => $this->when( + $request->routeIs('api.pipeline-templates.versions.show'), + fn () => $this->definition, + ), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Models/ContentPipeline.php b/app/Models/ContentPipeline.php index 70b23bb..671bab6 100755 --- a/app/Models/ContentPipeline.php +++ b/app/Models/ContentPipeline.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; /** * @property string $id @@ -22,7 +23,7 @@ */ class ContentPipeline extends Model { - use HasFactory, HasUlids; + use HasFactory, HasUlids, SoftDeletes; protected $fillable = ['space_id', 'name', 'stages', 'trigger_config', 'is_active']; diff --git a/app/Models/PipelineTemplate.php b/app/Models/PipelineTemplate.php new file mode 100644 index 0000000..ece4ebe --- /dev/null +++ b/app/Models/PipelineTemplate.php @@ -0,0 +1,91 @@ + $versions + * @property-read \Illuminate\Database\Eloquent\Collection|PipelineTemplateVersion|null $latestVersion + * @property-read \Illuminate\Database\Eloquent\Collection $installs + * @property-read \Illuminate\Database\Eloquent\Collection $ratings + */ +class PipelineTemplate extends Model +{ + use HasFactory, HasUlids, SoftDeletes; + + protected $fillable = [ + 'space_id', + 'name', + 'slug', + 'description', + 'category', + 'icon', + 'schema_version', + 'author_name', + 'author_url', + 'downloads_count', + ]; + + protected $casts = [ + 'is_published' => 'boolean', + 'downloads_count' => 'integer', + ]; + + public function space(): BelongsTo + { + return $this->belongsTo(Space::class); + } + + public function versions(): HasMany + { + return $this->hasMany(PipelineTemplateVersion::class, 'template_id'); + } + + public function latestVersion(): HasOne + { + return $this->hasOne(PipelineTemplateVersion::class, 'template_id')->where('is_latest', true); + } + + public function installs(): HasMany + { + return $this->hasMany(PipelineTemplateInstall::class, 'template_id'); + } + + public function ratings(): HasMany + { + return $this->hasMany(PipelineTemplateRating::class, 'template_id'); + } + + public function isGlobal(): bool + { + return $this->space_id === null; + } + + public function averageRating(): float + { + return (float) $this->ratings()->avg('rating'); + } +} diff --git a/app/Models/PipelineTemplateInstall.php b/app/Models/PipelineTemplateInstall.php new file mode 100644 index 0000000..9466fb8 --- /dev/null +++ b/app/Models/PipelineTemplateInstall.php @@ -0,0 +1,56 @@ + 'datetime', + 'config_overrides' => 'array', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(PipelineTemplate::class, 'template_id'); + } + + public function templateVersion(): BelongsTo + { + return $this->belongsTo(PipelineTemplateVersion::class, 'version_id'); + } + + public function space(): BelongsTo + { + return $this->belongsTo(Space::class, 'space_id'); + } +} diff --git a/app/Models/PipelineTemplateRating.php b/app/Models/PipelineTemplateRating.php new file mode 100644 index 0000000..cba7fcf --- /dev/null +++ b/app/Models/PipelineTemplateRating.php @@ -0,0 +1,45 @@ + 'integer', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(PipelineTemplate::class, 'template_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/PipelineTemplateVersion.php b/app/Models/PipelineTemplateVersion.php new file mode 100644 index 0000000..f4a1150 --- /dev/null +++ b/app/Models/PipelineTemplateVersion.php @@ -0,0 +1,52 @@ + $installs + */ +class PipelineTemplateVersion extends Model +{ + use HasFactory, HasUlids; + + protected $fillable = [ + 'template_id', + 'version', + 'definition', + 'changelog', + 'is_latest', + 'published_at', + ]; + + protected $casts = [ + 'definition' => 'array', + 'is_latest' => 'boolean', + 'published_at' => 'datetime', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(PipelineTemplate::class, 'template_id'); + } + + public function installs(): HasMany + { + return $this->hasMany(PipelineTemplateInstall::class, 'version_id'); + } +} diff --git a/app/Plugin/HookRegistry.php b/app/Plugin/HookRegistry.php index 254d95f..2e0499b 100644 --- a/app/Plugin/HookRegistry.php +++ b/app/Plugin/HookRegistry.php @@ -37,6 +37,12 @@ class HookRegistry /** @var array */ private array $vueComponents = []; + /** @var array */ + private array $templateCategories = []; + + /** @var array>, author: string|null, url: string|null}> */ + private array $templatePacks = []; + // ── Pipeline stages ──────────────────────────────────────────────────────── public function registerPipelineStage(string $stageName, Closure $handler): void @@ -251,4 +257,81 @@ public function getVueComponents(): array { return $this->vueComponents; } + // ── Template categories ──────────────────────────────────────────────────── + + /** + * Register a custom pipeline template category. + * + * @param array{slug: string, label: string, description?: string|null, icon?: string|null} $category + */ + public function registerTemplateCategory(array $category): void + { + $this->templateCategories[] = [ + 'slug' => $category['slug'], + 'label' => $category['label'], + 'description' => $category['description'] ?? null, + 'icon' => $category['icon'] ?? null, + ]; + } + + /** + * @return array + */ + public function getTemplateCategories(): array + { + return $this->templateCategories; + } + + /** + * Get all registered category slugs (built-in + plugin-registered). + * + * @return array + */ + public function getTemplateCategorySlugs(): array + { + return array_column($this->templateCategories, 'slug'); + } + + // ── Template packs ───────────────────────────────────────────────────────── + + /** + * Register a pack of pipeline templates bundled with a plugin. + * + * @param array{id: string, name: string, templates: array>, author?: string|null, url?: string|null} $pack + */ + public function registerTemplatePack(array $pack): void + { + $this->templatePacks[] = [ + 'id' => $pack['id'], + 'name' => $pack['name'], + 'templates' => $pack['templates'], + 'author' => $pack['author'] ?? null, + 'url' => $pack['url'] ?? null, + ]; + } + + /** + * @return array>, author: string|null, url: string|null}> + */ + public function getTemplatePacks(): array + { + return $this->templatePacks; + } + + /** + * Retrieve all template definitions from all registered packs. + * + * @return array> + */ + public function getAllPackTemplates(): array + { + $result = []; + foreach ($this->templatePacks as $pack) { + foreach ($pack['templates'] as $template) { + $result[] = array_merge($template, ['_pack_id' => $pack['id']]); + } + } + + return $result; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 62a51eb..76cb0ae 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -47,6 +47,7 @@ public function register(): void // ── Authorization ────────────────────────────────────────────────── // ── Plugin system ────────────────────────────────────────────────────── $this->app->singleton(HookRegistry::class); + $this->app->singleton(\App\Services\PipelineTemplates\TemplateHookIntegrationService::class); $this->app->singleton(PluginLoader::class, fn ($app) => new PluginLoader($app)); $this->app->singleton(AuthorizationService::class); diff --git a/app/Services/PipelineTemplates/PersonaResolver.php b/app/Services/PipelineTemplates/PersonaResolver.php new file mode 100644 index 0000000..bad925e --- /dev/null +++ b/app/Services/PipelineTemplates/PersonaResolver.php @@ -0,0 +1,56 @@ +> $personaDefinitions + * @return array + */ + public function resolvePersonas(array $personaDefinitions, Space $space): array + { + $resolved = []; + foreach ($personaDefinitions as $def) { + $ref = $def['persona_ref'] ?? ($def['name'] ?? ''); + $name = $def['name'] ?? $ref; + if (empty($ref)) { + continue; + } + $persona = $this->findExistingPersona($space, $name) + ?? $this->createPersona($space, $def, $name); + $resolved[$ref] = $persona; + } + + return $resolved; + } + + private function findExistingPersona(Space $space, string $name): ?Persona + { + /** @var Persona|null */ + return $space->personas()->where('name', $name)->first(); + } + + /** @param array $def */ + private function createPersona(Space $space, array $def, string $name): Persona + { + return Persona::create([ + 'space_id' => $space->id, + 'name' => $name, + 'role' => $def['role'] ?? 'creator', + 'system_prompt' => $def['system_prompt'] ?? '', + 'capabilities' => $def['capabilities'] ?? ['content_generation'], + 'model_config' => $def['model_config'] ?? [ + 'model' => config('numen.models.generation', 'claude-sonnet-4-6'), + 'temperature' => 0.7, + 'max_tokens' => 4096, + ], + 'voice_guidelines' => $def['voice_guidelines'] ?? null, + 'constraints' => $def['constraints'] ?? null, + 'is_active' => true, + ]); + } +} diff --git a/app/Services/PipelineTemplates/PipelineTemplateInstallService.php b/app/Services/PipelineTemplates/PipelineTemplateInstallService.php new file mode 100644 index 0000000..7aa578a --- /dev/null +++ b/app/Services/PipelineTemplates/PipelineTemplateInstallService.php @@ -0,0 +1,104 @@ + $variableValues + * @param array $configOverrides + */ + public function install( + PipelineTemplateVersion $version, + Space $space, + array $variableValues = [], + array $configOverrides = [], + ): PipelineTemplateInstall { + return DB::transaction(function () use ($version, $space, $variableValues, $configOverrides): PipelineTemplateInstall { + $definition = $this->variableResolver->resolve($version->definition, $variableValues); + $personaDefs = $definition['personas'] ?? []; + $personas = $this->personaResolver->resolvePersonas($personaDefs, $space); + $stages = $this->buildStages($definition['stages'] ?? [], $personas); + $pipelineName = $definition['settings']['name'] + ?? $version->template->name + ?? 'Imported Pipeline'; + + $pipeline = ContentPipeline::create([ + 'space_id' => $space->id, + 'name' => $pipelineName, + 'stages' => $stages, + 'trigger_config' => $definition['settings']['trigger_config'] ?? [], + 'is_active' => true, + ]); + + return PipelineTemplateInstall::create([ + 'template_id' => $version->template_id, + 'version_id' => $version->id, + 'space_id' => $space->id, + 'pipeline_id' => $pipeline->id, + 'installed_at' => now(), + 'config_overrides' => empty($configOverrides) ? null : $configOverrides, + ]); + }); + } + + public function uninstall(PipelineTemplateInstall $install): void + { + DB::transaction(function () use ($install): void { + if ($install->pipeline_id !== null) { + /** @var ContentPipeline|null $pipeline */ + $pipeline = ContentPipeline::find($install->pipeline_id); + $pipeline?->delete(); + } + $install->delete(); + }); + } + + public function update( + PipelineTemplateInstall $install, + PipelineTemplateVersion $newVersion, + ): PipelineTemplateInstall { + return DB::transaction(function () use ($install, $newVersion): PipelineTemplateInstall { + $configOverrides = $install->config_overrides ?? []; + + if ($install->pipeline_id !== null) { + /** @var ContentPipeline|null $pipeline */ + $pipeline = ContentPipeline::find($install->pipeline_id); + $pipeline?->delete(); + } + + $newInstall = $this->install($newVersion, $install->space, [], $configOverrides); + $install->delete(); + + return $newInstall; + }); + } + + /** + * @param array> $stages + * @param array $personas + * @return array> + */ + private function buildStages(array $stages, array $personas): array + { + return array_map(function (array $stage) use ($personas): array { + $ref = $stage['persona_ref'] ?? null; + if ($ref !== null && isset($personas[$ref])) { + $stage['persona_id'] = $personas[$ref]->id; + } + + return $stage; + }, $stages); + } +} diff --git a/app/Services/PipelineTemplates/PipelineTemplateService.php b/app/Services/PipelineTemplates/PipelineTemplateService.php new file mode 100644 index 0000000..f9a247d --- /dev/null +++ b/app/Services/PipelineTemplates/PipelineTemplateService.php @@ -0,0 +1,220 @@ + $data */ + public function create(Space $space, array $data): PipelineTemplate + { + $slug = $data['slug'] ?? Str::slug($data['name'] ?? ''); + + $template = (new PipelineTemplate)->forceFill([ + 'space_id' => $space->id, + 'name' => $data['name'], + 'slug' => $this->uniqueSlug($slug), + 'description' => $data['description'] ?? null, + 'category' => $data['category'] ?? null, + 'icon' => $data['icon'] ?? null, + 'schema_version' => $data['schema_version'] ?? '1.0', + 'is_published' => false, + 'author_name' => $data['author_name'] ?? null, + 'author_url' => $data['author_url'] ?? null, + ]); + $template->save(); + + return $template; + } + + /** @param array $data */ + public function update(PipelineTemplate $template, array $data): PipelineTemplate + { + $fillable = ['name', 'description', 'category', 'icon', 'author_name', 'author_url']; + $updates = array_intersect_key($data, array_flip($fillable)); + + if (isset($data['slug'])) { + $updates['slug'] = $data['slug'] === $template->slug + ? $template->slug + : $this->uniqueSlug($data['slug'], $template->id); + } + + $template->update($updates); + + return $template->refresh(); + } + + public function delete(PipelineTemplate $template): void + { + $template->delete(); + } + + public function publish(PipelineTemplate $template): void + { + $template->forceFill(['is_published' => true, 'space_id' => null])->save(); + } + + public function unpublish(PipelineTemplate $template): void + { + $template->forceFill(['is_published' => false])->save(); + } + + // ------------------------------------------------------------------------- + // Version management + // ------------------------------------------------------------------------- + + /** @param array $definition */ + public function createVersion( + PipelineTemplate $template, + array $definition, + string $version, + ?string $changelog = null, + ): PipelineTemplateVersion { + $result = $this->validator->validate($definition); + + if (! $result->isValid()) { + throw new InvalidArgumentException( + 'Template definition is invalid: '.implode('; ', $result->errors()), + ); + } + + return DB::transaction(function () use ($template, $definition, $version, $changelog): PipelineTemplateVersion { + $template->versions()->where('is_latest', true)->update(['is_latest' => false]); + + return PipelineTemplateVersion::create([ + 'template_id' => $template->id, + 'version' => $version, + 'definition' => $definition, + 'changelog' => $changelog, + 'is_latest' => true, + 'published_at' => now(), + ]); + }); + } + + // ------------------------------------------------------------------------- + // Import / Export + // ------------------------------------------------------------------------- + + /** @return array */ + public function export(PipelineTemplate $template): array + { + /** @var PipelineTemplateVersion|null $latestVersion */ + $latestVersion = $template->versions()->where('is_latest', true)->first(); + + return [ + 'numen_export' => '1.0', + 'exported_at' => now()->toIso8601String(), + 'template' => [ + 'name' => $template->name, + 'slug' => $template->slug, + 'description' => $template->description, + 'category' => $template->category, + 'icon' => $template->icon, + 'schema_version' => $template->schema_version, + 'author_name' => $template->author_name, + 'author_url' => $template->author_url, + ], + 'version' => $latestVersion ? [ + 'version' => $latestVersion->version, + 'changelog' => $latestVersion->changelog, + 'definition' => $latestVersion->definition, + ] : null, + ]; + } + + /** @param array $data */ + public function import(Space $space, array $data): PipelineTemplate + { + if (! isset($data['template'])) { + throw new InvalidArgumentException('Import data is missing the "template" key.'); + } + + return DB::transaction(function () use ($space, $data): PipelineTemplate { + $templateData = $data['template']; + $template = $this->create($space, $templateData); + + if (isset($data['version']) && is_array($data['version'])) { + $v = $data['version']; + $this->createVersion( + $template, + $v['definition'] ?? [], + $v['version'] ?? '1.0.0', + $v['changelog'] ?? null, + ); + } + + return $template->refresh(); + }); + } + + public function exportToFile(PipelineTemplate $template): string + { + $payload = $this->export($template); + $filename = 'pipeline-templates/'.$template->slug.'-'.now()->format('Ymd_His').'.json'; + + Storage::put($filename, (string) json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + return Storage::path($filename); + } + + public function importFromFile(Space $space, string $path): PipelineTemplate + { + if (! file_exists($path)) { + throw new RuntimeException("Import file not found: {$path}"); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException("Unable to read file: {$path}"); + } + + /** @var array $data */ + $data = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + + return $this->import($space, $data); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function uniqueSlug(string $base, ?string $excludeId = null): string + { + $slug = $base !== '' ? $base : 'template'; + $count = 0; + $candidate = $slug; + + do { + $query = PipelineTemplate::withTrashed()->where('slug', $candidate); + if ($excludeId !== null) { + $query->where('id', '!=', $excludeId); + } + $exists = $query->exists(); + + if ($exists) { + $count++; + $candidate = $slug.'-'.$count; + } + } while ($exists); + + return $candidate; + } +} diff --git a/app/Services/PipelineTemplates/TemplateDefinitionBuilder.php b/app/Services/PipelineTemplates/TemplateDefinitionBuilder.php new file mode 100644 index 0000000..708ff4d --- /dev/null +++ b/app/Services/PipelineTemplates/TemplateDefinitionBuilder.php @@ -0,0 +1,153 @@ +> */ + private array $stages = []; + + /** @var array> */ + private array $personas = []; + + /** @var array */ + private array $settings = []; + + /** @var array> */ + private array $variables = []; + + public function __construct(private readonly TemplateSchemaValidator $validator) {} + + public function version(string $version): static + { + $this->version = $version; + + return $this; + } + + /** @param array $config */ + public function addStage( + string $type, + string $name, + array $config = [], + ?string $personaRef = null, + ?string $provider = null, + bool $enabled = true, + ): static { + $stage = ['type' => $type, 'name' => $name, 'config' => $config, 'enabled' => $enabled]; + if ($personaRef !== null) { + $stage['persona_ref'] = $personaRef; + } + if ($provider !== null) { + $stage['provider'] = $provider; + } + $this->stages[] = $stage; + + return $this; + } + + public function addPersona( + string $ref, + string $name, + string $systemPrompt, + string $llmProvider, + string $llmModel, + string $voiceGuidelines = '', + ): static { + $persona = [ + 'ref' => $ref, + 'name' => $name, + 'system_prompt' => $systemPrompt, + 'llm_provider' => $llmProvider, + 'llm_model' => $llmModel, + ]; + if ($voiceGuidelines !== '') { + $persona['voice_guidelines'] = $voiceGuidelines; + } + $this->personas[] = $persona; + + return $this; + } + + /** + * @param array $options Required for select/multiselect types. + */ + public function addVariable( + string $key, + string $type, + string $label, + mixed $default = null, + bool $required = false, + array $options = [], + ): static { + $variable = ['key' => $key, 'type' => $type, 'label' => $label, 'required' => $required]; + if ($default !== null) { + $variable['default'] = $default; + } + if ($options !== []) { + $variable['options'] = $options; + } + $this->variables[] = $variable; + + return $this; + } + + /** @param array $settings */ + public function setSettings(array $settings): static + { + $this->settings = array_merge($this->settings, $settings); + + return $this; + } + + /** + * Build and validate the definition. Throws on invalid schema. + * + * @return array + * + * @throws InvalidArgumentException + */ + public function build(): array + { + $definition = [ + 'version' => $this->version, + 'stages' => $this->stages, + 'personas' => $this->personas, + 'settings' => $this->settings, + ]; + if ($this->variables !== []) { + $definition['variables'] = $this->variables; + } + $result = $this->validator->validate($definition); + if (! $result->isValid()) { + throw new InvalidArgumentException('Invalid template definition: '.implode('; ', $result->errors())); + } + + return $definition; + } + + /** + * Build without throwing; returns definition + ValidationResult. + * + * @return array{definition: array, result: ValidationResult} + */ + public function buildWithValidation(): array + { + $definition = [ + 'version' => $this->version, + 'stages' => $this->stages, + 'personas' => $this->personas, + 'settings' => $this->settings, + ]; + if ($this->variables !== []) { + $definition['variables'] = $this->variables; + } + + return ['definition' => $definition, 'result' => $this->validator->validate($definition)]; + } +} diff --git a/app/Services/PipelineTemplates/TemplateHookIntegrationService.php b/app/Services/PipelineTemplates/TemplateHookIntegrationService.php new file mode 100644 index 0000000..f0f43e1 --- /dev/null +++ b/app/Services/PipelineTemplates/TemplateHookIntegrationService.php @@ -0,0 +1,118 @@ +registry->getAllPackTemplates() as $def) { + try { + $this->upsertPackTemplate($space, $def); + } catch (\Throwable $e) { + Log::warning('[TemplateHookIntegration] Failed to sync pack template.', [ + 'pack_id' => $def['_pack_id'] ?? 'unknown', + 'name' => $def['name'] ?? 'unknown', + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * Return all categories: built-in defaults merged with plugin-registered. + * + * @return array + */ + public function getAvailableCategories(): array + { + $builtin = [ + ['slug' => 'blog', 'label' => 'Blog', 'description' => null, 'icon' => null], + ['slug' => 'social_media', 'label' => 'Social Media', 'description' => null, 'icon' => null], + ['slug' => 'seo', 'label' => 'SEO', 'description' => null, 'icon' => null], + ['slug' => 'ecommerce', 'label' => 'E-Commerce', 'description' => null, 'icon' => null], + ['slug' => 'newsletter', 'label' => 'Newsletter', 'description' => null, 'icon' => null], + ['slug' => 'technical', 'label' => 'Technical', 'description' => null, 'icon' => null], + ['slug' => 'custom', 'label' => 'Custom', 'description' => null, 'icon' => null], + ]; + + return array_merge($builtin, $this->registry->getTemplateCategories()); + } + + /** + * Return all registered template packs. + * + * @return array>, author: string|null, url: string|null}> + */ + public function getTemplatePacks(): array + { + return $this->registry->getTemplatePacks(); + } + + /** @param array $def */ + private function upsertPackTemplate(Space $space, array $def): void + { + if (empty($def['name']) || empty($def['definition'])) { + return; + } + + // Validate definition before persisting + /** @var array $definition */ + $definition = $def['definition']; + if (! $this->validator->validate($definition)->isValid()) { + Log::warning('[TemplateHookIntegration] Invalid template definition from pack, skipping.', [ + 'name' => $def['name'], + ]); + + return; + } + + // Check if a template with this slug already exists for the space + $slug = $def['slug'] ?? \Illuminate\Support\Str::slug((string) $def['name']); + $existing = \App\Models\PipelineTemplate::where('space_id', $space->id) + ->where('slug', $slug) + ->first(); + + if ($existing) { + return; // Don't overwrite user customisations + } + + $template = $this->templateService->create($space, [ + 'name' => $def['name'], + 'slug' => $slug, + 'description' => $def['description'] ?? null, + 'category' => $def['category'] ?? 'custom', + 'icon' => $def['icon'] ?? null, + 'author_name' => $def['author_name'] ?? null, + 'author_url' => $def['author_url'] ?? null, + ]); + + $this->templateService->createVersion( + $template, + $definition, + (string) ($def['version'] ?? '1.0.0'), + 'Imported from plugin pack', + ); + } +} diff --git a/app/Services/PipelineTemplates/TemplateSchemaValidator.php b/app/Services/PipelineTemplates/TemplateSchemaValidator.php new file mode 100644 index 0000000..7a24392 --- /dev/null +++ b/app/Services/PipelineTemplates/TemplateSchemaValidator.php @@ -0,0 +1,269 @@ + $definition */ + public function validate(array $definition): ValidationResult + { + $errors = []; + $warnings = []; + // Accept both 'version' and 'schema_version' field names + $schemaVersion = $definition['version'] ?? $definition['schema_version'] ?? null; + if ($schemaVersion === null) { + $errors[] = 'Missing required field: version'; + } elseif (! in_array($schemaVersion, self::SUPPORTED_VERSIONS, true)) { + $errors[] = "Unsupported schema version: \"{$schemaVersion}\""; + } + if (! isset($definition['stages'])) { + $errors[] = 'Missing required field: stages'; + } elseif (! is_array($definition['stages'])) { + $errors[] = 'Field "stages" must be an array'; + } elseif (empty($definition['stages'])) { + $errors[] = 'Field "stages" must contain at least one stage'; + } else { + $errors = array_merge($errors, $this->validateStages($definition['stages'])); + } + if (isset($definition['personas'])) { + if (! is_array($definition['personas'])) { + $errors[] = 'Field "personas" must be an array'; + } else { + [$personaErrors, $personaWarnings] = $this->validatePersonas($definition['personas']); + $errors = array_merge($errors, $personaErrors); + $warnings = array_merge($warnings, $personaWarnings); + } + } + if (isset($definition['settings'])) { + if (! is_array($definition['settings'])) { + $errors[] = 'Field "settings" must be an object/array'; + } else { + $errors = array_merge($errors, $this->validateSettings($definition['settings'])); + } + } + if (isset($definition['variables'])) { + if (! is_array($definition['variables'])) { + $errors[] = 'Field "variables" must be an array'; + } else { + $errors = array_merge($errors, $this->validateVariables($definition['variables'])); + } + } + if (empty($errors) && isset($definition['personas'])) { + $refs = $this->collectPersonaRefs($definition['personas']); + $errors = array_merge($errors, $this->validatePersonaRefs($definition['stages'] ?? [], $refs)); + } + + return empty($errors) ? ValidationResult::valid($warnings) : ValidationResult::invalid($errors, $warnings); + } + + /** + * @param array $stages + * @return array + */ + private function validateStages(array $stages): array + { + $errors = []; + $allowed = $this->getAllowedStageTypes(); + foreach ($stages as $i => $stage) { + $p = "stages[$i]"; + if (! is_array($stage)) { + $errors[] = "{$p}: Each stage must be an object"; + + continue; + } + if (! isset($stage['type']) || ! is_string($stage['type']) || $stage['type'] === '') { + $errors[] = "{$p}: Missing required field \"type\""; + } elseif (! in_array($stage['type'], $allowed, true)) { + $errors[] = "{$p}: Unknown stage type \"{$stage['type']}\". Allowed: ".implode(', ', $allowed); + } + if (! isset($stage['name']) || ! is_string($stage['name']) || $stage['name'] === '') { + $errors[] = "{$p}: Missing required field \"name\""; + } + if (isset($stage['config']) && ! is_array($stage['config'])) { + $errors[] = "{$p}: Field \"config\" must be an object/array"; + } + if (isset($stage['persona_ref']) && ! is_string($stage['persona_ref'])) { + $errors[] = "{$p}: Field \"persona_ref\" must be a string"; + } + if (isset($stage['provider']) && ! is_string($stage['provider'])) { + $errors[] = "{$p}: Field \"provider\" must be a string"; + } + if (isset($stage['enabled']) && ! is_bool($stage['enabled'])) { + $errors[] = "{$p}: Field \"enabled\" must be a boolean"; + } + } + + return $errors; + } + + /** + * @param array $personas + * @return array{0: array, 1: array} + */ + private function validatePersonas(array $personas): array + { + $errors = []; + $warnings = []; + $refs = []; + foreach ($personas as $i => $persona) { + $p = "personas[$i]"; + if (! is_array($persona)) { + $errors[] = "{$p}: Each persona must be an object"; + + continue; + } + foreach (['ref', 'name', 'system_prompt', 'llm_provider', 'llm_model'] as $field) { + if (! isset($persona[$field]) || ! is_string($persona[$field]) || $persona[$field] === '') { + $errors[] = "{$p}: Missing required field \"{$field}\""; + } + } + if (isset($persona['voice_guidelines']) && ! is_string($persona['voice_guidelines'])) { + $errors[] = "{$p}: Field \"voice_guidelines\" must be a string"; + } + if (isset($persona['ref'])) { + if (in_array($persona['ref'], $refs, true)) { + $errors[] = "{$p}: Duplicate persona ref \"{$persona['ref']}\""; + } else { + $refs[] = $persona['ref']; + } + } + } + + return [$errors, $warnings]; + } + + /** + * @param array $settings + * @return array + */ + private function validateSettings(array $settings): array + { + $errors = []; + if (isset($settings['auto_publish']) && ! is_bool($settings['auto_publish'])) { + $errors[] = 'settings.auto_publish must be a boolean'; + } + if (isset($settings['review_required']) && ! is_bool($settings['review_required'])) { + $errors[] = 'settings.review_required must be a boolean'; + } + if (isset($settings['max_retries']) && ! is_int($settings['max_retries'])) { + $errors[] = 'settings.max_retries must be an integer'; + } + if (isset($settings['timeout_seconds']) && ! is_int($settings['timeout_seconds'])) { + $errors[] = 'settings.timeout_seconds must be an integer'; + } + + return $errors; + } + + /** + * @param array $variables + * @return array + */ + private function validateVariables(array $variables): array + { + $errors = []; + $keys = []; + foreach ($variables as $i => $variable) { + $p = "variables[$i]"; + if (! is_array($variable)) { + $errors[] = "{$p}: Each variable must be an object"; + + continue; + } + if (! isset($variable['key']) || ! is_string($variable['key']) || $variable['key'] === '') { + $errors[] = "{$p}: Missing required field \"key\""; + } else { + if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $variable['key'])) { + $errors[] = "{$p}: Field \"key\" must be a valid identifier"; + } + if (in_array($variable['key'], $keys, true)) { + $errors[] = "{$p}: Duplicate variable key \"{$variable['key']}\""; + } else { + $keys[] = $variable['key']; + } + } + if (! isset($variable['type']) || ! is_string($variable['type'])) { + $errors[] = "{$p}: Missing required field \"type\""; + } elseif (! in_array($variable['type'], self::VARIABLE_TYPES, true)) { + $errors[] = "{$p}: Invalid variable type \"{$variable['type']}\""; + } + if (! isset($variable['label']) || ! is_string($variable['label']) || $variable['label'] === '') { + $errors[] = "{$p}: Missing required field \"label\""; + } + if (isset($variable['required']) && ! is_bool($variable['required'])) { + $errors[] = "{$p}: Field \"required\" must be a boolean"; + } + if (isset($variable['type']) && in_array($variable['type'], ['select', 'multiselect'], true)) { + if (! isset($variable['options']) || ! is_array($variable['options']) || empty($variable['options'])) { + $errors[] = "{$p}: Variable of type \"{$variable['type']}\" must include a non-empty \"options\" array"; + } + } + } + + return $errors; + } + + /** + * @param array $stages + * @param array $personaRefs + * @return array + */ + private function validatePersonaRefs(array $stages, array $personaRefs): array + { + $errors = []; + foreach ($stages as $i => $stage) { + if (! is_array($stage)) { + continue; + } + if (isset($stage['persona_ref']) && is_string($stage['persona_ref'])) { + if (! in_array($stage['persona_ref'], $personaRefs, true)) { + $errors[] = "stages[$i]: persona_ref \"{$stage['persona_ref']}\" does not reference any defined persona"; + } + } + } + + return $errors; + } + + /** + * @param array $personas + * @return array + */ + private function collectPersonaRefs(array $personas): array + { + $refs = []; + foreach ($personas as $persona) { + if (is_array($persona) && isset($persona['ref']) && is_string($persona['ref'])) { + $refs[] = $persona['ref']; + } + } + + return $refs; + } + + /** @return array */ + private function getAllowedStageTypes(): array + { + return array_unique(array_merge( + self::CORE_STAGE_TYPES, + $this->hookRegistry->getRegisteredPipelineStageTypes(), + )); + } +} diff --git a/app/Services/PipelineTemplates/ValidationResult.php b/app/Services/PipelineTemplates/ValidationResult.php new file mode 100644 index 0000000..10cce2c --- /dev/null +++ b/app/Services/PipelineTemplates/ValidationResult.php @@ -0,0 +1,47 @@ + $errors + * @param array $warnings + */ + public function __construct( + private readonly array $errors = [], + private readonly array $warnings = [], + ) {} + + /** @param array $warnings */ + public static function valid(array $warnings = []): self + { + return new self([], $warnings); + } + + /** + * @param array $errors + * @param array $warnings + */ + public static function invalid(array $errors, array $warnings = []): self + { + return new self($errors, $warnings); + } + + public function isValid(): bool + { + return empty($this->errors); + } + + /** @return array */ + public function errors(): array + { + return $this->errors; + } + + /** @return array */ + public function warnings(): array + { + return $this->warnings; + } +} diff --git a/app/Services/PipelineTemplates/VariableResolver.php b/app/Services/PipelineTemplates/VariableResolver.php new file mode 100644 index 0000000..248a3ad --- /dev/null +++ b/app/Services/PipelineTemplates/VariableResolver.php @@ -0,0 +1,107 @@ + $definition + * @param array $values + * @return array + */ + public function resolve(array $definition, array $values): array + { + $variables = $definition['variables'] ?? []; + $this->validateRequiredVariables($variables, $values); + $coercedValues = $this->coerceValues($variables, $values); + + return $this->replacePlaceholders($definition, $coercedValues); + } + + /** + * @param array> $variables + * @param array $values + */ + private function validateRequiredVariables(array $variables, array $values): void + { + $missing = []; + foreach ($variables as $variable) { + $name = $variable['name'] ?? ''; + $required = $variable['required'] ?? true; + if ($required && ! array_key_exists($name, $values)) { + $missing[] = $name; + } + } + if (! empty($missing)) { + throw new InvalidArgumentException( + 'Missing required template variables: '.implode(', ', $missing), + ); + } + } + + /** + * @param array> $variables + * @param array $values + * @return array + */ + private function coerceValues(array $variables, array $values): array + { + $result = $values; + foreach ($variables as $variable) { + $name = $variable['name'] ?? ''; + $type = $variable['type'] ?? 'string'; + if (! array_key_exists($name, $values)) { + if (array_key_exists('default', $variable)) { + $result[$name] = $this->coerce($variable['default'], $type); + } + + continue; + } + $result[$name] = $this->coerce($values[$name], $type); + } + + return $result; + } + + private function coerce(mixed $value, string $type): mixed + { + return match ($type) { + 'number' => is_numeric($value) ? $value + 0 : (float) $value, + 'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value, + 'select' => (string) $value, + 'multiselect' => is_array($value) ? $value : [(string) $value], + default => (string) $value, + }; + } + + /** + * @param array $definition + * @param array $values + * @return array + */ + private function replacePlaceholders(array $definition, array $values): array + { + array_walk_recursive($definition, function (mixed &$item) use ($values): void { + if (! is_string($item)) { + return; + } + $item = preg_replace_callback( + '/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/', + function (array $matches) use ($values): string { + $key = $matches[1]; + if (! array_key_exists($key, $values)) { + return $matches[0]; + } + $val = $values[$key]; + + return is_array($val) ? implode(', ', $val) : (string) $val; + }, + $item, + ) ?? $item; + }); + + return $definition; + } +} diff --git a/database/factories/PipelineTemplateFactory.php b/database/factories/PipelineTemplateFactory.php new file mode 100644 index 0000000..b7ba347 --- /dev/null +++ b/database/factories/PipelineTemplateFactory.php @@ -0,0 +1,47 @@ +faker->words(3, true).' Template'; + + return [ + 'space_id' => null, + 'name' => $name, + 'slug' => Str::slug($name).'-'.Str::random(6), + 'description' => $this->faker->sentence(), + 'category' => $this->faker->randomElement(['content', 'seo', 'social', 'email', 'ecommerce']), + 'icon' => $this->faker->randomElement(['document', 'sparkles', 'megaphone', 'mail', 'shopping-cart']), + 'schema_version' => '1.0', + 'is_published' => false, + 'author_name' => $this->faker->name(), + 'author_url' => $this->faker->url(), + 'downloads_count' => 0, + ]; + } + + public function published(): static + { + return $this->state(['is_published' => true]); + } + + public function global(): static + { + return $this->state(['space_id' => null]); + } + + public function forSpace(Space $space): static + { + return $this->state(['space_id' => $space->id]); + } +} diff --git a/database/factories/PipelineTemplateInstallFactory.php b/database/factories/PipelineTemplateInstallFactory.php new file mode 100644 index 0000000..5f1f646 --- /dev/null +++ b/database/factories/PipelineTemplateInstallFactory.php @@ -0,0 +1,31 @@ + PipelineTemplate::factory(), + 'version_id' => PipelineTemplateVersion::factory(), + 'space_id' => Space::factory(), + 'pipeline_id' => null, + 'installed_at' => now(), + 'config_overrides' => null, + ]; + } + + public function withConfigOverrides(array $overrides = []): static + { + return $this->state(['config_overrides' => $overrides ?: ['persona_id' => 'custom']]); + } +} diff --git a/database/factories/PipelineTemplateRatingFactory.php b/database/factories/PipelineTemplateRatingFactory.php new file mode 100644 index 0000000..58ad4d8 --- /dev/null +++ b/database/factories/PipelineTemplateRatingFactory.php @@ -0,0 +1,28 @@ + PipelineTemplate::factory(), + 'user_id' => User::factory(), + 'rating' => $this->faker->numberBetween(1, 5), + 'review' => $this->faker->optional()->sentence(), + ]; + } + + public function withRating(int $rating): static + { + return $this->state(['rating' => $rating]); + } +} diff --git a/database/factories/PipelineTemplateVersionFactory.php b/database/factories/PipelineTemplateVersionFactory.php new file mode 100644 index 0000000..44d53ee --- /dev/null +++ b/database/factories/PipelineTemplateVersionFactory.php @@ -0,0 +1,44 @@ + PipelineTemplate::factory(), + 'version' => $this->faker->semver(), + 'definition' => [ + 'stages' => [ + ['name' => 'generate', 'type' => 'ai_generate', 'persona_role' => 'creator'], + ['name' => 'review', 'type' => 'human_gate'], + ['name' => 'publish', 'type' => 'auto_publish'], + ], + 'trigger' => 'manual', + ], + 'changelog' => $this->faker->sentence(), + 'is_latest' => false, + 'published_at' => null, + ]; + } + + public function latest(): static + { + return $this->state([ + 'is_latest' => true, + 'published_at' => now(), + ]); + } + + public function published(): static + { + return $this->state(['published_at' => now()]); + } +} diff --git a/database/migrations/2026_03_15_500001_create_pipeline_templates_table.php b/database/migrations/2026_03_15_500001_create_pipeline_templates_table.php new file mode 100644 index 0000000..bb72dbb --- /dev/null +++ b/database/migrations/2026_03_15_500001_create_pipeline_templates_table.php @@ -0,0 +1,35 @@ +ulid('id')->primary(); + $table->string('space_id', 26)->nullable()->index(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('category')->nullable(); + $table->string('icon')->nullable(); + $table->string('schema_version')->default('1.0'); + $table->boolean('is_published')->default(false); + $table->string('author_name')->nullable(); + $table->string('author_url')->nullable(); + $table->unsignedBigInteger('downloads_count')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_templates'); + } +}; diff --git a/database/migrations/2026_03_15_500002_create_pipeline_template_versions_table.php b/database/migrations/2026_03_15_500002_create_pipeline_template_versions_table.php new file mode 100644 index 0000000..39c96c3 --- /dev/null +++ b/database/migrations/2026_03_15_500002_create_pipeline_template_versions_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->string('template_id', 26)->index(); + $table->string('version'); + $table->json('definition'); + $table->text('changelog')->nullable(); + $table->boolean('is_latest')->default(false); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_template_versions'); + } +}; diff --git a/database/migrations/2026_03_15_500003_create_pipeline_template_installs_table.php b/database/migrations/2026_03_15_500003_create_pipeline_template_installs_table.php new file mode 100644 index 0000000..83dd59d --- /dev/null +++ b/database/migrations/2026_03_15_500003_create_pipeline_template_installs_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->string('template_id', 26)->index(); + $table->string('version_id', 26)->index(); + $table->string('space_id', 26)->index(); + $table->string('pipeline_id', 26)->nullable()->index(); + $table->timestamp('installed_at')->useCurrent(); + $table->json('config_overrides')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_template_installs'); + } +}; diff --git a/database/migrations/2026_03_15_500004_create_pipeline_template_ratings_table.php b/database/migrations/2026_03_15_500004_create_pipeline_template_ratings_table.php new file mode 100644 index 0000000..b5ccd28 --- /dev/null +++ b/database/migrations/2026_03_15_500004_create_pipeline_template_ratings_table.php @@ -0,0 +1,27 @@ +ulid('id')->primary(); + $table->string('template_id', 26)->index(); + $table->string('user_id', 26)->index(); + $table->tinyInteger('rating')->unsigned(); + $table->text('review')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_template_ratings'); + } +}; diff --git a/database/migrations/2026_03_15_500005_add_soft_deletes_to_content_pipelines.php b/database/migrations/2026_03_15_500005_add_soft_deletes_to_content_pipelines.php new file mode 100644 index 0000000..d3ca582 --- /dev/null +++ b/database/migrations/2026_03_15_500005_add_soft_deletes_to_content_pipelines.php @@ -0,0 +1,24 @@ +softDeletes(); + }); + } + } + + public function down(): void + { + Schema::table('content_pipelines', function (Blueprint $table): void { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/seeders/BuiltInTemplateSeeder.php b/database/seeders/BuiltInTemplateSeeder.php new file mode 100644 index 0000000..9eaaafc --- /dev/null +++ b/database/seeders/BuiltInTemplateSeeder.php @@ -0,0 +1,304 @@ +templates() as $spec) { + $exists = PipelineTemplate::withTrashed() + ->where('slug', $spec['slug']) + ->whereNull('space_id') + ->exists(); + + if ($exists) { + continue; + } + + $template = PipelineTemplate::create([ + 'space_id' => null, + 'name' => $spec['name'], + 'slug' => $spec['slug'], + 'description' => $spec['description'], + 'category' => $spec['category'], + 'icon' => $spec['icon'], + 'schema_version' => '1.0', + 'is_published' => true, + 'author_name' => 'Numen', + 'author_url' => 'https://numen.ai', + ]); + + PipelineTemplateVersion::create([ + 'template_id' => $template->id, + 'version' => '1.0.0', + 'definition' => $spec['definition'], + 'changelog' => 'Initial built-in template.', + 'is_latest' => true, + 'published_at' => now(), + ]); + } + } + + /** @return array> */ + private function templates(): array + { + return [ + $this->blogPostPipeline(), + $this->socialMediaCampaign(), + $this->productDescription(), + $this->emailNewsletter(), + $this->pressRelease(), + $this->landingPage(), + $this->technicalDocumentation(), + $this->videoScript(), + ]; + } + + /** @return array */ + private function blogPostPipeline(): array + { + return [ + 'name' => 'Blog Post Pipeline', + 'slug' => 'blog-post-pipeline', + 'description' => 'Full blog post creation pipeline: outline, draft, SEO review, human gate, publish.', + 'category' => 'content', + 'icon' => 'pencil', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'blog_writer', 'name' => 'Blog Writer', 'system_prompt' => 'You are an expert blog writer producing engaging, well-structured long-form content.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Conversational, informative, SEO-aware.'], + ['ref' => 'seo_reviewer', 'name' => 'SEO Reviewer', 'system_prompt' => 'You are an SEO specialist who reviews content for keyword density, meta descriptions, and readability.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o-mini'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Outline', 'persona_ref' => 'blog_writer', 'config' => ['prompt_template' => 'Create a detailed outline for a blog post about {topic}.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Draft', 'persona_ref' => 'blog_writer', 'config' => ['prompt_template' => 'Write a full blog post based on this outline: {outline}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'SEO Check', 'persona_ref' => 'seo_reviewer', 'config' => ['prompt_template' => 'Review this blog post for SEO quality: {draft}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Editor Approval', 'config' => ['instructions' => 'Review and approve the blog post before publishing.'], 'enabled' => true], + ['type' => 'auto_publish', 'name' => 'Publish', 'config' => ['target' => 'cms'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 300], + 'variables' => [ + ['key' => 'topic', 'type' => 'string', 'label' => 'Blog Topic', 'required' => true], + ['key' => 'keywords', 'type' => 'text', 'label' => 'Target Keywords', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function socialMediaCampaign(): array + { + return [ + 'name' => 'Social Media Campaign', + 'slug' => 'social-media-campaign', + 'description' => 'Generate platform-specific social media posts from a single campaign brief.', + 'category' => 'social', + 'icon' => 'megaphone', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'social_copywriter', 'name' => 'Social Copywriter', 'system_prompt' => 'You are a social media expert who crafts engaging, platform-native copy.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Punchy, trend-aware, emoji-friendly.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Twitter Post', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Write a Twitter post (max 280 chars) for: {campaign_brief}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'LinkedIn Post', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Write a professional LinkedIn post for: {campaign_brief}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Instagram Caption', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Write an Instagram caption with hashtags for: {campaign_brief}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Brand Voice Check', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Check the following posts for brand consistency: {posts}'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 2, 'timeout_seconds' => 180], + 'variables' => [ + ['key' => 'campaign_brief', 'type' => 'text', 'label' => 'Campaign Brief', 'required' => true], + ['key' => 'brand_tone', 'type' => 'select', 'label' => 'Brand Tone', 'required' => false, 'options' => ['professional', 'playful', 'inspirational', 'edgy']], + ], + ], + ]; + } + + /** @return array */ + private function productDescription(): array + { + return [ + 'name' => 'Product Description', + 'slug' => 'product-description', + 'description' => 'Generate compelling product descriptions with feature bullets and SEO optimization.', + 'category' => 'ecommerce', + 'icon' => 'cart', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'product_copywriter', 'name' => 'Product Copywriter', 'system_prompt' => 'You write compelling product descriptions that convert browsers into buyers.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Benefit-led, clear, persuasive.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Long Description', 'persona_ref' => 'product_copywriter', 'config' => ['prompt_template' => 'Write a detailed product description for {product_name}: {product_details}'], 'enabled' => true], + ['type' => 'ai_transform', 'name' => 'Feature Bullets', 'persona_ref' => 'product_copywriter', 'config' => ['prompt_template' => 'Extract 5 key feature bullets from: {long_description}'], 'enabled' => true], + ['type' => 'ai_transform', 'name' => 'Meta Description', 'persona_ref' => 'product_copywriter', 'config' => ['prompt_template' => 'Write a 160-char SEO meta description for: {long_description}'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => true, 'review_required' => false, 'max_retries' => 3, 'timeout_seconds' => 120], + 'variables' => [ + ['key' => 'product_name', 'type' => 'string', 'label' => 'Product Name', 'required' => true], + ['key' => 'product_details', 'type' => 'text', 'label' => 'Product Details', 'required' => true], + ['key' => 'target_audience', 'type' => 'string', 'label' => 'Target Audience', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function emailNewsletter(): array + { + return [ + 'name' => 'Email Newsletter', + 'slug' => 'email-newsletter', + 'description' => 'Create full email newsletters with subject line variants and CTA optimisation.', + 'category' => 'email', + 'icon' => 'email', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'email_writer', 'name' => 'Email Writer', 'system_prompt' => 'You are an expert email marketer who writes newsletters people actually read.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Warm, direct, action-oriented.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Subject Lines', 'persona_ref' => 'email_writer', 'config' => ['prompt_template' => 'Write 5 email subject line variants for a newsletter about: {newsletter_topic}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Newsletter Body', 'persona_ref' => 'email_writer', 'config' => ['prompt_template' => 'Write a newsletter body for: {newsletter_topic}. Include intro, 3 sections, and a CTA.'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Spam Check', 'persona_ref' => 'email_writer', 'config' => ['prompt_template' => 'Review this email for spam triggers and deliverability issues: {body}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Final Approval', 'config' => ['instructions' => 'Review and approve the newsletter before sending.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 2, 'timeout_seconds' => 240], + 'variables' => [ + ['key' => 'newsletter_topic', 'type' => 'string', 'label' => 'Newsletter Topic', 'required' => true], + ['key' => 'send_date', 'type' => 'string', 'label' => 'Planned Send Date', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function pressRelease(): array + { + return [ + 'name' => 'Press Release', + 'slug' => 'press-release', + 'description' => 'Generate professional press releases with datelines, quotes, and boilerplate.', + 'category' => 'pr', + 'icon' => 'newspaper', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'pr_writer', 'name' => 'PR Writer', 'system_prompt' => 'You are a public relations professional who writes clear, factual press releases in AP style.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Formal, objective, newswire-ready.'], + ['ref' => 'legal_reviewer', 'name' => 'Legal Reviewer', 'system_prompt' => 'You review press releases for legal risks, false claims, and compliance issues.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o-mini'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Draft Press Release', 'persona_ref' => 'pr_writer', 'config' => ['prompt_template' => 'Write a press release for: {announcement}. Company: {company_name}. Contact: {contact_info}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Legal Review', 'persona_ref' => 'legal_reviewer', 'config' => ['prompt_template' => 'Review this press release for legal risks: {draft}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Executive Sign-off', 'config' => ['instructions' => 'Route to executive for approval before distribution.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 2, 'timeout_seconds' => 300], + 'variables' => [ + ['key' => 'announcement', 'type' => 'text', 'label' => 'Announcement Details', 'required' => true], + ['key' => 'company_name', 'type' => 'string', 'label' => 'Company Name', 'required' => true], + ['key' => 'contact_info', 'type' => 'text', 'label' => 'Media Contact Info', 'required' => true], + ['key' => 'embargo_date', 'type' => 'string', 'label' => 'Embargo Date', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function landingPage(): array + { + return [ + 'name' => 'Landing Page', + 'slug' => 'landing-page', + 'description' => 'Craft high-converting landing page copy: headline, hero, benefits, social proof, CTA.', + 'category' => 'marketing', + 'icon' => 'rocket', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'conversion_copywriter', 'name' => 'Conversion Copywriter', 'system_prompt' => 'You are a direct-response copywriter specialising in high-converting landing pages.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Benefit-driven, urgent, customer-centric.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Headline Variants', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write 5 headline variants for a landing page about {offer}.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Hero Section', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write hero copy (headline, subheadline, CTA) for: {offer}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Benefits Section', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write 6 key benefits for: {offer}. Target audience: {target_audience}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'CTA Copy', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write 3 CTA button text variants and supporting copy for: {offer}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Conversion Review', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Review the landing page copy for CRO: {full_copy}'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 360], + 'variables' => [ + ['key' => 'offer', 'type' => 'text', 'label' => 'Offer / Product', 'required' => true], + ['key' => 'target_audience', 'type' => 'text', 'label' => 'Target Audience', 'required' => true], + ['key' => 'unique_value', 'type' => 'text', 'label' => 'Unique Value Prop', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function technicalDocumentation(): array + { + return [ + 'name' => 'Technical Documentation', + 'slug' => 'technical-documentation', + 'description' => 'Generate developer-ready technical documentation: API refs, guides, and FAQs.', + 'category' => 'technical', + 'icon' => 'books', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'technical_writer', 'name' => 'Technical Writer', 'system_prompt' => 'You are a technical writer who creates clear, accurate developer documentation.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Precise, scannable, code-inclusive.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Overview Section', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Write an overview section for documentation of {feature_name}.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Getting Started', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Write a Getting Started guide for {feature_name}. Include code examples.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'API Reference', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Document the following API endpoints in OpenAPI style: {api_spec}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'FAQ Section', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Write an FAQ section for {feature_name} based on common developer questions.'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Accuracy Review', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Review the following documentation for technical accuracy and completeness: {draft}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Engineer Review', 'config' => ['instructions' => 'Have an engineer verify technical accuracy.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 480], + 'variables' => [ + ['key' => 'feature_name', 'type' => 'string', 'label' => 'Feature / Product Name', 'required' => true], + ['key' => 'api_spec', 'type' => 'text', 'label' => 'API Spec / Endpoints', 'required' => false], + ['key' => 'audience_level', 'type' => 'select', 'label' => 'Audience Level', 'required' => false, 'options' => ['beginner', 'intermediate', 'advanced']], + ], + ], + ]; + } + + /** @return array */ + private function videoScript(): array + { + return [ + 'name' => 'Video Script', + 'slug' => 'video-script', + 'description' => 'Generate full video scripts with hook, scene breakdown, narration, and call-to-action.', + 'category' => 'video', + 'icon' => 'film', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'script_writer', 'name' => 'Script Writer', 'system_prompt' => 'You write compelling video scripts for YouTube, explainer videos, and ads.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Engaging, visual, paced for on-camera delivery.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Hook', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Write a compelling 5-second hook for a video about: {video_topic}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Scene Breakdown', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Create a scene-by-scene breakdown for a {video_length}-minute video about: {video_topic}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Full Script', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Write the full narration script. Topic: {video_topic}. Scenes: {scene_breakdown}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Pacing Review', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Review this script for pacing, clarity, and engagement: {full_script}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Director Sign-off', 'config' => ['instructions' => 'Have the director review and approve the script.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 360], + 'variables' => [ + ['key' => 'video_topic', 'type' => 'string', 'label' => 'Video Topic', 'required' => true], + ['key' => 'video_length', 'type' => 'select', 'label' => 'Video Length (min)', 'required' => true, 'options' => ['1', '2', '3', '5', '10', '15']], + ['key' => 'video_style', 'type' => 'select', 'label' => 'Video Style', 'required' => false, 'options' => ['explainer', 'tutorial', 'testimonial', 'ad', 'documentary']], + ], + ], + ]; + } +} diff --git a/docs/pipeline-templates.md b/docs/pipeline-templates.md new file mode 100644 index 0000000..2c5a691 --- /dev/null +++ b/docs/pipeline-templates.md @@ -0,0 +1,296 @@ +# AI Pipeline Templates & Preset Library + +**New in v0.10.0.** Reusable AI pipeline templates for accelerated content creation workflows. + +--- + +## Overview + +Pipeline Templates allow you to create, share, and install reusable AI content pipelines. Instead of configuring personas, stages, and variables from scratch for every brief, teams can install a pre-built template that handles the entire workflow. + +The system includes 8 production-ready built-in templates, a community library with ratings and installs, template versioning, and plugin hooks for extending the library with custom template packs. + +### Use Cases + +- **Marketing teams** — Reuse campaign templates across projects (Blog Post, Social Media, Landing Page) +- **Product teams** — Standardize e-commerce workflows (Product Descriptions) +- **Agency teams** — White-label templates for client work (Email Newsletter, Press Release) +- **Technical writers** — Consistent documentation templates (Technical Documentation) +- **Content creators** — Video production at scale (Video Script) + +--- + +## Built-in Templates + +Numen ships with 8 production-ready templates covering common content workflows: + +### 1. Blog Post Pipeline +**Slug:** `blog-post-pipeline` + +Complete blog post creation with outline, draft, SEO review, and editorial gate. + +### 2. Social Media Campaign +**Slug:** `social-media-campaign` + +Generate platform-specific posts (Twitter, LinkedIn, Instagram) from a single brief. + +### 3. Product Description +**Slug:** `product-description` + +Generate compelling product copy with feature bullets and SEO meta descriptions. + +### 4. Email Newsletter +**Slug:** `email-newsletter` + +Create full newsletters with subject line variants, body, and spam check. + +### 5. Press Release +**Slug:** `press-release` + +Generate professional press releases in AP style with legal review gate. + +### 6. Landing Page +**Slug:** `landing-page` + +Craft high-converting landing page copy with headlines, hero, benefits, and CTAs. + +### 7. Technical Documentation +**Slug:** `technical-documentation` + +Generate developer-ready docs: overview, getting started, API reference, FAQ. + +### 8. Video Script +**Slug:** `video-script` + +Create full video scripts with hooks, scene breakdown, narration, and pacing review. + +--- + +## Template Schema Format + +### Template Metadata + +```json +{ + "name": "Template Display Name", + "slug": "kebab-case-slug", + "description": "Short description of what the template does", + "category": "content|social|ecommerce|email|pr|marketing|technical|video", + "icon": "pencil|megaphone|cart|email|newspaper|rocket|books|film", + "schema_version": "1.0", + "author_name": "Creator Name", + "author_url": "https://example.com", + "is_published": true +} +``` + +### Definition Schema (JSON) + +The `definition` field in `PipelineTemplateVersion` contains the full pipeline configuration: + +```json +{ + "version": "1.0", + "personas": [ + { + "ref": "unique_ref", + "name": "Display Name", + "system_prompt": "System prompt for the persona...", + "llm_provider": "openai|anthropic|azure-openai|together-ai", + "llm_model": "gpt-4o|claude-3-sonnet|...", + "voice_guidelines": "Brand voice and tone notes..." + } + ], + "stages": [ + { + "type": "ai_generate|ai_review|ai_transform|human_gate|auto_publish", + "name": "Stage Display Name", + "persona_ref": "reference_to_personas[*].ref", + "config": { + "prompt_template": "Prompt with {variables}...", + "instructions": "For human_gate and auto_publish stages" + }, + "enabled": true + } + ], + "settings": { + "auto_publish": false, + "review_required": true, + "max_retries": 3, + "timeout_seconds": 300 + }, + "variables": [ + { + "key": "variable_key", + "type": "string|text|select|textarea", + "label": "Display Label", + "required": true, + "options": ["option1", "option2"] + } + ] +} +``` + +--- + +## API Endpoints Reference + +### List Templates (Paginated) + +``` +GET /api/v1/spaces/{space}/pipeline-templates +``` + +Query: `page`, `per_page`, `category`, `published`, `sort` + +### Create Template + +``` +POST /api/v1/spaces/{space}/pipeline-templates +``` + +### Get Template + +``` +GET /api/v1/spaces/{space}/pipeline-templates/{template} +``` + +### Update Template + +``` +PATCH /api/v1/spaces/{space}/pipeline-templates/{template} +``` + +### Delete Template + +``` +DELETE /api/v1/spaces/{space}/pipeline-templates/{template} +``` + +### Publish/Unpublish Template + +``` +POST /api/v1/spaces/{space}/pipeline-templates/{template}/publish +POST /api/v1/spaces/{space}/pipeline-templates/{template}/unpublish +``` + +### Manage Versions + +``` +GET /api/v1/spaces/{space}/pipeline-templates/{template}/versions +POST /api/v1/spaces/{space}/pipeline-templates/{template}/versions +GET /api/v1/spaces/{space}/pipeline-templates/{template}/versions/{version} +``` + +### Template Installation + +``` +POST /api/v1/spaces/{space}/pipeline-templates/installs/{version} +PATCH /api/v1/spaces/{space}/pipeline-templates/installs/{install} +DELETE /api/v1/spaces/{space}/pipeline-templates/installs/{install} +``` + +**Rate Limited:** 5 requests per minute per user + +### Template Ratings + +``` +GET /api/v1/spaces/{space}/pipeline-templates/{template}/ratings +POST /api/v1/spaces/{space}/pipeline-templates/{template}/ratings +``` + +--- + +## Install Wizard Flow + +1. **Select Template** — Browse templates by category, rating, or download count +2. **Review Definition** — Display template metadata, ratings, definition (personas, stages, variables) +3. **Confirm Installation** — Creates install record and Brief with template configuration +4. **Launch Pipeline** — Users can immediately start briefs using the template + +--- + +## Plugin Hook Integration + +### Register Template Category + +```php +$hookRegistry = app(\App\Plugin\HookRegistry::class); + +$hookRegistry->registerTemplateCategory([ + 'slug' => 'custom-category', + 'label' => 'Custom Content Type', + 'description' => 'Templates for custom content workflows', + 'icon' => 'star', +]); +``` + +### Register Template Pack + +```php +$hookRegistry->registerTemplatePack([ + 'id' => 'my-plugin-templates', + 'name' => 'My Plugin Templates', + 'author' => 'Your Company', + 'url' => 'https://example.com', + 'templates' => [ + [ + 'name' => 'Custom Template 1', + 'slug' => 'custom-template-1', + 'description' => 'Description...', + 'category' => 'custom-category', + 'icon' => 'star', + 'schema_version' => '1.0', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ /* ... */ ], + 'stages' => [ /* ... */ ], + 'variables' => [ /* ... */ ], + ], + ], + ], +]); +``` + +--- + +## Security Notes + +### Space Scoping + +- **Global** (`space_id = null`) — Available to all spaces (built-in, published templates) +- **Space-scoped** (`space_id = `) — Private to a specific space + +### Role-Based Access Control + +| Operation | Permission | Role | +|-----------|-----------|------| +| Create | `templates.create` | Editor, Admin | +| Update | `templates.update` | Editor, Admin | +| Publish | `templates.publish` | Admin | +| Delete | `templates.delete` | Admin | +| Install | `templates.install` | Editor, Author | +| Rate | `templates.rate` | Any authenticated user | + +### Audit Logging + +All template operations logged for compliance: +- Template creation/update/deletion +- Version publishing +- Template installation +- Template ratings + +### API Rate Limiting + +- **Install template:** 5 requests per minute +- **Rating:** 1 request per minute +- **Template creation:** 10 requests per minute + +--- + +## Related Documentation + +- [Pipeline Architecture](../architecture/pipelines.md) +- [Plugin System](../plugins.md) +- [RBAC Guide](../RBAC_GUIDE.md) + diff --git a/openapi.yaml b/openapi.yaml index 769afac..798a669 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2108,6 +2108,384 @@ paths: '200': description: List of supported formats + /spaces/{space}/pipeline-templates: + get: + operationId: listPipelineTemplates + summary: List pipeline templates + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: page + in: query + schema: + type: integer + - name: per_page + in: query + schema: + type: integer + - name: category + in: query + schema: + type: string + responses: + '200': + description: Paginated list of templates + '401': + description: Unauthenticated + + post: + operationId: createPipelineTemplate + summary: Create pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + slug: + type: string + responses: + '201': + description: Template created + '401': + description: Unauthenticated + + /spaces/{space}/pipeline-templates/{template}: + get: + operationId: getPipelineTemplate + summary: Get pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: Template details + + patch: + operationId: updatePipelineTemplate + summary: Update pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Template updated + + delete: + operationId: deletePipelineTemplate + summary: Delete pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '204': + description: Template deleted + + /spaces/{space}/pipeline-templates/{template}/publish: + post: + operationId: publishPipelineTemplate + summary: Publish pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: Template published + + /spaces/{space}/pipeline-templates/{template}/unpublish: + post: + operationId: unpublishPipelineTemplate + summary: Unpublish pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: Template unpublished + + /spaces/{space}/pipeline-templates/{template}/versions: + get: + operationId: listVersions + summary: List template versions + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: List of versions + + post: + operationId: createVersion + summary: Create template version + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + version: + type: string + definition: + type: object + responses: + '201': + description: Version created + + /spaces/{space}/pipeline-templates/{template}/versions/{version}: + get: + operationId: getVersion + summary: Get template version + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + - name: version + in: path + required: true + schema: + type: string + responses: + '200': + description: Version details + + /spaces/{space}/pipeline-templates/installs/{version}: + post: + operationId: installTemplate + summary: Install template version + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: version + in: path + required: true + schema: + type: string + responses: + '201': + description: Template installed (rate-limited 5/min) + '429': + description: Rate limited + + /spaces/{space}/pipeline-templates/installs/{install}: + patch: + operationId: updateInstall + summary: Update template install + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: install + in: path + required: true + schema: + type: string + responses: + '200': + description: Install updated + + delete: + operationId: deleteInstall + summary: Delete template install + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: install + in: path + required: true + schema: + type: string + responses: + '204': + description: Install deleted + + /spaces/{space}/pipeline-templates/{template}/ratings: + get: + operationId: listRatings + summary: List template ratings + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: List of ratings + + post: + operationId: rateTemplate + summary: Rate pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + rating: + type: integer + minimum: 1 + maximum: 5 + comment: + type: string + responses: + '201': + description: Rating created + tags: - name: Content description: Public content delivery endpoints (60 req/min) @@ -2133,3 +2511,5 @@ tags: description: AI-powered content repurposing to 8 formats (authenticated) - name: Format Templates description: Custom format templates for content repurposing (authenticated) + - name: Pipeline Templates + description: Reusable AI pipeline templates for accelerated content workflows (authenticated, 5 req/min install) diff --git a/resources/js/Components/Templates/RatingPanel.vue b/resources/js/Components/Templates/RatingPanel.vue new file mode 100644 index 0000000..0d95e53 --- /dev/null +++ b/resources/js/Components/Templates/RatingPanel.vue @@ -0,0 +1,155 @@ + + +