php-agentic/Services/PlanTemplateService.php
darbs-claude 91ee71b8a1
Some checks failed
CI / PHP 8.3 (pull_request) Failing after 1m37s
CI / PHP 8.4 (pull_request) Failing after 1m36s
fix: improve template variable error messages (#30)
Enhance `validateVariables()` in `PlanTemplateService` to produce
actionable errors instead of the generic "Required variable '...' is missing".

Changes:
- Extracted `buildVariableError()` helper that composes the message from
  the variable's `description`, `format`, `example`, and `examples` fields
- Added `naming_convention` key to the returned array so callers have
  a constant reminder that variable names use snake_case
- Added a `NAMING_CONVENTION` private const to avoid string duplication

Tests (6 new cases in `PlanTemplateServiceTest`):
- description included in error message
- single `example` value included
- `examples` list (first two) included
- `format` hint included alongside example
- `naming_convention` present in both valid and invalid results
- bare variable (no description) still produces useful "missing" message

Closes #30

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 11:48:27 +00:00

423 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
/**
* Plan Template Service - creates plans from YAML templates.
*
* Templates define reusable plan structures with phases, tasks,
* and variable substitution for customisation.
*/
class PlanTemplateService
{
protected string $templatesPath;
public function __construct()
{
$this->templatesPath = resource_path('plan-templates');
}
/**
* List all available templates.
*/
public function list(): Collection
{
if (! File::isDirectory($this->templatesPath)) {
return collect();
}
return collect(File::files($this->templatesPath))
->filter(fn ($file) => $file->getExtension() === 'yaml' || $file->getExtension() === 'yml')
->map(function ($file) {
$content = Yaml::parseFile($file->getPathname());
// Transform variables from keyed dict to indexed array for display
$variables = collect($content['variables'] ?? [])
->map(fn ($config, $name) => [
'name' => $name,
'description' => $config['description'] ?? null,
'default' => $config['default'] ?? null,
'required' => $config['required'] ?? false,
])
->values()
->toArray();
return [
'slug' => pathinfo($file->getFilename(), PATHINFO_FILENAME),
'name' => $content['name'] ?? Str::title(pathinfo($file->getFilename(), PATHINFO_FILENAME)),
'description' => $content['description'] ?? null,
'category' => $content['category'] ?? 'general',
'phases_count' => count($content['phases'] ?? []),
'variables' => $variables,
'path' => $file->getPathname(),
];
})
->sortBy('name')
->values();
}
/**
* List all available templates as array.
*/
public function listTemplates(): array
{
return $this->list()->toArray();
}
/**
* Preview a template with variable substitution.
*/
public function previewTemplate(string $templateSlug, array $variables = []): ?array
{
$template = $this->get($templateSlug);
if (! $template) {
return null;
}
// Apply variable substitution
$template = $this->substituteVariables($template, $variables);
// Build preview structure
return [
'slug' => $templateSlug,
'name' => $template['name'] ?? $templateSlug,
'description' => $template['description'] ?? null,
'category' => $template['category'] ?? 'general',
'context' => $this->buildContext($template, $variables),
'phases' => collect($template['phases'] ?? [])->map(function ($phase, $order) {
return [
'order' => $order + 1,
'name' => $phase['name'] ?? 'Phase '.($order + 1),
'description' => $phase['description'] ?? null,
'tasks' => collect($phase['tasks'] ?? [])->map(function ($task) {
return is_string($task) ? ['name' => $task] : $task;
})->toArray(),
];
})->toArray(),
'variables_applied' => $variables,
'guidelines' => $template['guidelines'] ?? [],
];
}
/**
* Get a specific template by slug.
*/
public function get(string $slug): ?array
{
$path = $this->templatesPath.'/'.$slug.'.yaml';
if (! File::exists($path)) {
$path = $this->templatesPath.'/'.$slug.'.yml';
}
if (! File::exists($path)) {
return null;
}
$content = Yaml::parseFile($path);
$content['slug'] = $slug;
return $content;
}
/**
* Create a plan from a template.
*/
public function createPlan(
string $templateSlug,
array $variables = [],
array $options = [],
?Workspace $workspace = null
): ?AgentPlan {
$template = $this->get($templateSlug);
if (! $template) {
return null;
}
// Replace variables in template
$template = $this->substituteVariables($template, $variables);
// Generate plan title and slug
$title = $options['title'] ?? $template['name'];
$planSlug = $options['slug'] ?? AgentPlan::generateSlug($title);
// Build context from template
$context = $this->buildContext($template, $variables);
// Create the plan
$plan = AgentPlan::create([
'workspace_id' => $workspace?->id ?? $options['workspace_id'] ?? null,
'slug' => $planSlug,
'title' => $title,
'description' => $template['description'] ?? null,
'context' => $context,
'status' => ($options['activate'] ?? false) ? AgentPlan::STATUS_ACTIVE : AgentPlan::STATUS_DRAFT,
'metadata' => array_merge($template['metadata'] ?? [], [
'source' => 'template',
'template_slug' => $templateSlug,
'template_name' => $template['name'],
'variables' => $variables,
'created_at' => now()->toIso8601String(),
]),
]);
// Create phases
foreach ($template['phases'] ?? [] as $order => $phaseData) {
$tasks = [];
foreach ($phaseData['tasks'] ?? [] as $task) {
$tasks[] = is_string($task)
? ['name' => $task, 'status' => 'pending']
: array_merge(['status' => 'pending'], $task);
}
AgentPhase::create([
'agent_plan_id' => $plan->id,
'order' => $order + 1,
'name' => $phaseData['name'] ?? 'Phase '.($order + 1),
'description' => $phaseData['description'] ?? null,
'tasks' => $tasks,
'dependencies' => $phaseData['dependencies'] ?? null,
'metadata' => $phaseData['metadata'] ?? null,
]);
}
return $plan->fresh(['agentPhases']);
}
/**
* Extract variable placeholders from template.
*/
protected function extractVariables(array $template): array
{
$json = json_encode($template);
preg_match_all('/\{\{\s*(\w+)\s*\}\}/', $json, $matches);
$variables = array_unique($matches[1] ?? []);
// Check for variable definitions in template
$definitions = $template['variables'] ?? [];
return collect($variables)->map(function ($var) use ($definitions) {
$def = $definitions[$var] ?? [];
return [
'name' => $var,
'description' => $def['description'] ?? null,
'default' => $def['default'] ?? null,
'required' => $def['required'] ?? true,
];
})->values()->toArray();
}
/**
* Substitute variables in template content.
*
* Uses a safe replacement strategy that properly escapes values for JSON context
* to prevent corruption from special characters.
*/
protected function substituteVariables(array $template, array $variables): array
{
$json = json_encode($template, JSON_UNESCAPED_UNICODE);
foreach ($variables as $key => $value) {
// Sanitise value: only allow scalar values
if (! is_scalar($value) && $value !== null) {
continue;
}
// Escape the value for safe JSON string insertion
// json_encode wraps in quotes, so we extract just the escaped content
$escapedValue = $this->escapeForJson((string) $value);
$json = preg_replace(
'/\{\{\s*'.preg_quote($key, '/').'\s*\}\}/',
$escapedValue,
$json
);
}
// Apply defaults for unsubstituted variables
foreach ($template['variables'] ?? [] as $key => $def) {
if (isset($def['default']) && ! isset($variables[$key])) {
$escapedDefault = $this->escapeForJson((string) $def['default']);
$json = preg_replace(
'/\{\{\s*'.preg_quote($key, '/').'\s*\}\}/',
$escapedDefault,
$json
);
}
}
$result = json_decode($json, true);
// Validate JSON decode was successful
if ($result === null && json_last_error() !== JSON_ERROR_NONE) {
// Return original template if substitution corrupted the JSON
return $template;
}
return $result;
}
/**
* Escape a string value for safe insertion into a JSON string context.
*
* This handles special characters that would break JSON structure:
* - Backslashes, quotes, control characters
*/
protected function escapeForJson(string $value): string
{
// json_encode the value, then strip the surrounding quotes
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE);
// Handle encoding failure
if ($encoded === false) {
return '';
}
// Remove surrounding quotes from json_encode output
return substr($encoded, 1, -1);
}
/**
* Build context string from template.
*/
protected function buildContext(array $template, array $variables): ?string
{
$context = $template['context'] ?? null;
if (! $context) {
// Build default context
$lines = [];
$lines[] = "## Plan: {$template['name']}";
if ($template['description'] ?? null) {
$lines[] = "\n{$template['description']}";
}
if (! empty($variables)) {
$lines[] = "\n### Variables";
foreach ($variables as $key => $value) {
$lines[] = "- **{$key}**: {$value}";
}
}
if ($template['guidelines'] ?? null) {
$lines[] = "\n### Guidelines";
foreach ((array) $template['guidelines'] as $guideline) {
$lines[] = "- {$guideline}";
}
}
$context = implode("\n", $lines);
}
return $context;
}
/**
* Validate variables against template requirements.
*
* Returns a result array with:
* - valid: bool
* - errors: string[] actionable messages including description and examples
* - naming_convention: string reminder that variable names use snake_case
*/
public function validateVariables(string $templateSlug, array $variables): array
{
$template = $this->get($templateSlug);
if (! $template) {
return ['valid' => false, 'errors' => ['Template not found'], 'naming_convention' => self::NAMING_CONVENTION];
}
$errors = [];
foreach ($template['variables'] ?? [] as $name => $varDef) {
$required = $varDef['required'] ?? true;
if ($required && ! isset($variables[$name]) && ! isset($varDef['default'])) {
$errors[] = $this->buildVariableError($name, $varDef);
}
}
return [
'valid' => empty($errors),
'errors' => $errors,
'naming_convention' => self::NAMING_CONVENTION,
];
}
/**
* Naming convention reminder included in validation results.
*/
private const NAMING_CONVENTION = 'Variable names use snake_case (e.g. project_name, api_key)';
/**
* Build an actionable error message for a missing required variable.
*
* Incorporates the variable's description, example values, and expected
* format so the caller knows exactly what to provide.
*/
private function buildVariableError(string $name, array $varDef): string
{
$message = "Required variable '{$name}' is missing";
if (! empty($varDef['description'])) {
$message .= ": {$varDef['description']}";
}
$hints = [];
if (! empty($varDef['format'])) {
$hints[] = "expected format: {$varDef['format']}";
}
if (! empty($varDef['example'])) {
$hints[] = "example: '{$varDef['example']}'";
} elseif (! empty($varDef['examples'])) {
$exampleValues = is_array($varDef['examples'])
? array_slice($varDef['examples'], 0, 2)
: [$varDef['examples']];
$hints[] = "examples: '".implode("', '", $exampleValues)."'";
}
if (! empty($hints)) {
$message .= ' ('.implode('; ', $hints).')';
}
return $message;
}
/**
* Get templates by category.
*/
public function getByCategory(string $category): Collection
{
return $this->list()->filter(fn ($t) => $t['category'] === $category);
}
/**
* Get template categories.
*/
public function getCategories(): Collection
{
return $this->list()
->pluck('category')
->unique()
->sort()
->values();
}
}