diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1347cdc --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +APP_NAME=AskGVT Prompt Editor +APP_ENV=local +APP_DEBUG=true +APP_URL=http://localhost:8000 + +DB_CONNECTION=sqlite +DB_DATABASE=database/database.sqlite +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_USERNAME=postgres +DB_PASSWORD=secret diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e7746b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.env +/database/*.sqlite +/vendor/ +/public/database/ +/node_modules/ diff --git a/app/Controllers/PromptLineController.php b/app/Controllers/PromptLineController.php new file mode 100644 index 0000000..ff179d8 --- /dev/null +++ b/app/Controllers/PromptLineController.php @@ -0,0 +1,165 @@ +requireVersion((int) $params['version']); + $sectionId = $request->query('section') ? (int) $request->query('section') : null; + + return view('lines/form', [ + 'action' => '/versions/' . $version['id'] . '/lines/create', + 'version' => $version, + 'sections' => $this->sections->all(), + 'line' => ['section_id' => $sectionId, 'content' => '', 'order_index' => null, 'enabled' => 1], + 'errors' => [], + ]); + } + + public function store(Request $request, array $params): Response + { + $version = $this->requireVersion((int) $params['version']); + $payload = $this->validate($request); + if ($payload['errors']) { + return view('lines/form', [ + 'action' => '/versions/' . $version['id'] . '/lines/create', + 'version' => $version, + 'sections' => $this->sections->all(), + 'line' => $payload['data'], + 'errors' => $payload['errors'], + ]); + } + + $payload['data']['version_id'] = $version['id']; + $this->lines->create($payload['data']); + $this->lines->resequence($version['id']); + + return new RedirectResponse('/versions/' . $version['id'] . '?message=Line%20added'); + } + + public function edit(Request $request, array $params): Response + { + $line = $this->requireLine((int) $params['id']); + $version = $this->requireVersion((int) $line['version_id']); + + return view('lines/form', [ + 'action' => '/lines/' . $line['id'] . '/edit', + 'version' => $version, + 'sections' => $this->sections->all(), + 'line' => $line, + 'errors' => [], + ]); + } + + public function update(Request $request, array $params): Response + { + $line = $this->requireLine((int) $params['id']); + $version = $this->requireVersion((int) $line['version_id']); + $payload = $this->validate($request, $line); + if ($payload['errors']) { + return view('lines/form', [ + 'action' => '/lines/' . $line['id'] . '/edit', + 'version' => $version, + 'sections' => $this->sections->all(), + 'line' => array_merge($line, $payload['data']), + 'errors' => $payload['errors'], + ]); + } + + $this->lines->update($line['id'], $payload['data']); + $this->lines->resequence($version['id']); + + return new RedirectResponse('/versions/' . $version['id'] . '?message=Line%20updated'); + } + + public function destroy(Request $request, array $params): Response + { + $line = $this->requireLine((int) $params['id']); + $versionId = (int) $line['version_id']; + $this->lines->delete($line['id']); + $this->lines->resequence($versionId); + + return new RedirectResponse('/versions/' . $versionId . '?message=Line%20deleted'); + } + + public function move(Request $request, array $params): Response + { + $line = $this->requireLine((int) $params['id']); + $direction = $request->input('direction'); + $delta = $direction === 'up' ? -1 : 1; + $this->lines->move($line['id'], $delta); + + return new RedirectResponse('/versions/' . $line['version_id'] . '?message=Line%20reordered'); + } + + private function requireVersion(int $id): array + { + $version = $this->versions->find($id); + if (!$version) { + throw new RuntimeException('Prompt version not found.'); + } + + return $version; + } + + private function requireLine(int $id): array + { + $line = $this->lines->find($id); + if (!$line) { + throw new RuntimeException('Prompt line not found.'); + } + + return $line; + } + + /** + * @return array{data:array,errors:array} + */ + private function validate(Request $request, array $defaults = []): array + { + $data = [ + 'section_id' => $request->input('section_id', $defaults['section_id'] ?? null), + 'order_index' => $request->input('order_index', $defaults['order_index'] ?? null), + 'enabled' => $request->input('enabled', $defaults['enabled'] ?? 1) ? 1 : 0, + 'content' => trim((string) $request->input('content', $defaults['content'] ?? '')), + ]; + + if ($data['order_index'] === '' || $data['order_index'] === null) { + $data['order_index'] = null; + } else { + $data['order_index'] = (int) $data['order_index']; + } + + $errors = []; + if ($data['section_id'] === null || !$this->sections->find((int) $data['section_id'])) { + $errors['section_id'] = 'Please select a valid section.'; + } + if ($data['content'] === '') { + $errors['content'] = 'Content cannot be empty.'; + } + if ($data['order_index'] !== null && $data['order_index'] < 0) { + $errors['order_index'] = 'Order index must be zero or greater.'; + } + + return ['data' => $data, 'errors' => $errors]; + } +} diff --git a/app/Controllers/PromptSectionController.php b/app/Controllers/PromptSectionController.php new file mode 100644 index 0000000..72d657c --- /dev/null +++ b/app/Controllers/PromptSectionController.php @@ -0,0 +1,125 @@ + $this->sections->all(), + 'message' => $request->query('message'), + ]); + } + + public function create(Request $request): Response + { + return view('sections/form', [ + 'action' => '/sections/create', + 'section' => ['key' => '', 'title' => '', 'description' => '', 'order_index' => 0, 'enabled' => 1], + 'errors' => [], + ]); + } + + public function store(Request $request): Response + { + $payload = $this->validate($request); + if ($payload['errors']) { + return view('sections/form', [ + 'action' => '/sections/create', + 'section' => $payload['data'], + 'errors' => $payload['errors'], + ]); + } + + if ($this->sections->findByKey($payload['data']['key'])) { + $payload['errors']['key'] = 'A section with this key already exists.'; + return view('sections/form', [ + 'action' => '/sections/create', + 'section' => $payload['data'], + 'errors' => $payload['errors'], + ]); + } + + $this->sections->create($payload['data']); + + return new RedirectResponse('/sections?message=Section%20created'); + } + + public function edit(Request $request, array $params): Response + { + $section = $this->requireSection((int) $params['id']); + + return view('sections/form', [ + 'action' => '/sections/' . $section['id'] . '/edit', + 'section' => $section, + 'errors' => [], + ]); + } + + public function update(Request $request, array $params): Response + { + $section = $this->requireSection((int) $params['id']); + $payload = $this->validate($request, $section, false); + if ($payload['errors']) { + return view('sections/form', [ + 'action' => '/sections/' . $section['id'] . '/edit', + 'section' => array_merge($section, $payload['data']), + 'errors' => $payload['errors'], + ]); + } + + $this->sections->update($section['id'], $payload['data']); + + return new RedirectResponse('/sections?message=Section%20updated'); + } + + private function requireSection(int $id): array + { + $section = $this->sections->find($id); + if (!$section) { + throw new RuntimeException('Section not found.'); + } + + return $section; + } + + /** + * @return array{data:array,errors:array} + */ + private function validate(Request $request, array $defaults = [], bool $requireKey = true): array + { + $data = [ + 'key' => trim((string) $request->input('key', $defaults['key'] ?? '')), + 'title' => trim((string) $request->input('title', $defaults['title'] ?? '')), + 'description' => (string) $request->input('description', $defaults['description'] ?? ''), + 'order_index' => (int) $request->input('order_index', $defaults['order_index'] ?? 0), + 'enabled' => $request->input('enabled', $defaults['enabled'] ?? 1) ? 1 : 0, + ]; + + $errors = []; + if ($requireKey && $data['key'] === '') { + $errors['key'] = 'Key is required.'; + } + if ($data['title'] === '') { + $errors['title'] = 'Title is required.'; + } + if ($data['order_index'] < 0) { + $errors['order_index'] = 'Order index must be zero or greater.'; + } + + return ['data' => $data, 'errors' => $errors]; + } +} diff --git a/app/Controllers/PromptVersionController.php b/app/Controllers/PromptVersionController.php new file mode 100644 index 0000000..e28cb1d --- /dev/null +++ b/app/Controllers/PromptVersionController.php @@ -0,0 +1,198 @@ +versions->all(); + + return view('versions/index', [ + 'versions' => $versions, + 'statuses' => PromptVersionRepository::STATUSES, + 'message' => $request->query('message'), + ]); + } + + public function create(Request $request): Response + { + return view('versions/form', [ + 'action' => '/versions/create', + 'version' => ['prompt_name' => '', 'version_label' => '', 'status' => 'draft', 'notes' => ''], + 'statuses' => PromptVersionRepository::STATUSES, + 'errors' => [], + ]); + } + + public function store(Request $request): Response + { + $payload = $this->validate($request); + if ($payload['errors']) { + return view('versions/form', [ + 'action' => '/versions/create', + 'version' => $payload['data'], + 'statuses' => PromptVersionRepository::STATUSES, + 'errors' => $payload['errors'], + ]); + } + + $id = $this->versions->create($payload['data']); + + return new RedirectResponse('/versions/' . $id . '?message=Version%20created'); + } + + public function edit(Request $request, array $params): Response + { + $version = $this->requireVersion((int) $params['id']); + + return view('versions/form', [ + 'action' => '/versions/' . $version['id'] . '/edit', + 'version' => $version, + 'statuses' => PromptVersionRepository::STATUSES, + 'errors' => [], + ]); + } + + public function update(Request $request, array $params): Response + { + $version = $this->requireVersion((int) $params['id']); + $payload = $this->validate($request, $version); + if ($payload['errors']) { + return view('versions/form', [ + 'action' => '/versions/' . $version['id'] . '/edit', + 'version' => array_merge($version, $payload['data']), + 'statuses' => PromptVersionRepository::STATUSES, + 'errors' => $payload['errors'], + ]); + } + + $this->versions->update($version['id'], $payload['data']); + + return new RedirectResponse('/versions/' . $version['id'] . '?message=Version%20updated'); + } + + public function destroy(Request $request, array $params): Response + { + $version = $this->requireVersion((int) $params['id']); + $this->versions->delete($version['id']); + + return new RedirectResponse('/versions?message=Version%20deleted'); + } + + public function show(Request $request, array $params): Response + { + $version = $this->requireVersion((int) $params['id']); + $sectionId = $request->query('section') ? (int) $request->query('section') : null; + $sections = $this->sections->all(); + $lines = $this->lines->forVersion($version['id'], $sectionId, true); + + return view('versions/show', [ + 'version' => $version, + 'sections' => $sections, + 'selectedSection' => $sectionId, + 'lines' => $lines, + 'message' => $request->query('message'), + ]); + } + + public function updateStatus(Request $request, array $params): Response + { + $version = $this->requireVersion((int) $params['id']); + $status = $request->input('status'); + if (!is_string($status) || !in_array($status, PromptVersionRepository::STATUSES, true)) { + return view('versions/show', [ + 'version' => $version, + 'sections' => $this->sections->all(), + 'selectedSection' => null, + 'lines' => $this->lines->forVersion($version['id'], null, true), + 'message' => 'Invalid status value provided.', + ]); + } + + $this->versions->setStatus($version['id'], $status); + + return new RedirectResponse('/versions/' . $version['id'] . '?message=Status%20updated'); + } + + public function duplicate(Request $request, array $params): Response + { + $version = $this->requireVersion((int) $params['id']); + $label = trim((string) $request->input('version_label', '')); + if ($label === '') { + return new RedirectResponse('/versions/' . $version['id'] . '?message=Provide%20a%20label%20for%20duplication'); + } + + $copyId = $this->versions->duplicate($version['id'], $label); + + return new RedirectResponse('/versions/' . $copyId . '?message=Version%20duplicated'); + } + + public function renderPrompt(Request $request, array $params): Response + { + $version = $this->requireVersion((int) $params['id']); + $text = $this->renderer->renderText($version['id']); + + return Response::json([ + 'prompt_name' => $version['prompt_name'], + 'version_label' => $version['version_label'], + 'status' => $version['status'], + 'rendered_prompt' => $text, + ]); + } + + private function requireVersion(int $id): array + { + $version = $this->versions->find($id); + if (!$version) { + throw new RuntimeException('Prompt version not found.'); + } + + return $version; + } + + /** + * @return array{data:array,errors:array} + */ + private function validate(Request $request, array $defaults = []): array + { + $data = [ + 'prompt_name' => trim((string) $request->input('prompt_name', $defaults['prompt_name'] ?? '')), + 'version_label' => trim((string) $request->input('version_label', $defaults['version_label'] ?? '')), + 'status' => (string) $request->input('status', $defaults['status'] ?? 'draft'), + 'notes' => (string) $request->input('notes', $defaults['notes'] ?? ''), + ]; + + $errors = []; + if ($data['prompt_name'] === '') { + $errors['prompt_name'] = 'Prompt name is required.'; + } + if ($data['version_label'] === '') { + $errors['version_label'] = 'Version label is required.'; + } + if (!in_array($data['status'], PromptVersionRepository::STATUSES, true)) { + $errors['status'] = 'Status must be one of: ' . implode(', ', PromptVersionRepository::STATUSES); + } + + return ['data' => $data, 'errors' => $errors]; + } +} diff --git a/app/Database/Connection.php b/app/Database/Connection.php new file mode 100644 index 0000000..026c8c1 --- /dev/null +++ b/app/Database/Connection.php @@ -0,0 +1,94 @@ +|null */ + private static ?array $override = null; + + public static function pdo(): PDO + { + if (self::$pdo instanceof PDO) { + return self::$pdo; + } + + $config = self::$override ?? self::config(); + $driver = $config['driver'] ?? null; + + if ($driver === null) { + throw new RuntimeException('Database driver not configured.'); + } + + try { + if ($driver === 'sqlite') { + $database = $config['database'] ?? ':memory:'; + if ($database !== ':memory:') { + if (!str_starts_with($database, '/')) { + $database = BASE_PATH . '/' . ltrim($database, '/'); + } + + $dir = dirname($database); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + } + + $pdo = new PDO('sqlite:' . $database); + } elseif ($driver === 'pgsql') { + $host = $config['host'] ?? '127.0.0.1'; + $port = $config['port'] ?? '5432'; + $dbname = $config['database'] ?? ''; + $dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $dbname); + $pdo = new PDO($dsn, (string) ($config['username'] ?? ''), (string) ($config['password'] ?? '')); + } else { + throw new RuntimeException('Unsupported database driver: ' . $driver); + } + } catch (PDOException $exception) { + throw new RuntimeException('Failed to connect to database: ' . $exception->getMessage(), 0, $exception); + } + + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + self::$pdo = $pdo; + + return self::$pdo; + } + + public static function reset(): void + { + self::$pdo = null; + } + + /** + * @param array|null $config + */ + public static function override(?array $config): void + { + self::$override = $config; + self::reset(); + } + + /** + * @return array + */ + private static function config(): array + { + $default = config('database.default'); + $connections = config('database.connections', []); + + if (!is_string($default) || !isset($connections[$default])) { + throw new RuntimeException('Database configuration not found.'); + } + + return $connections[$default]; + } +} diff --git a/app/Database/Migrations/Migration.php b/app/Database/Migrations/Migration.php new file mode 100644 index 0000000..fa0cfbc --- /dev/null +++ b/app/Database/Migrations/Migration.php @@ -0,0 +1,14 @@ +ensureMigrationsTable($pdo); + + $files = glob(rtrim($this->path, '/') . '/*.php') ?: []; + sort($files); + + foreach ($files as $file) { + $name = basename($file); + if ($this->hasRun($pdo, $name)) { + continue; + } + + $migration = $this->resolve($file); + $pdo->beginTransaction(); + try { + $migration->up($pdo); + $this->markAsRun($pdo, $name); + $pdo->commit(); + } catch (RuntimeException $exception) { + $pdo->rollBack(); + throw $exception; + } + } + } + + public function rollbackLast(): void + { + $pdo = Connection::pdo(); + $this->ensureMigrationsTable($pdo); + $last = $pdo->query('SELECT name FROM migrations ORDER BY id DESC LIMIT 1'); + if (!$last) { + return; + } + + $row = $last->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return; + } + + $name = (string) $row['name']; + $file = rtrim($this->path, '/') . '/' . $name; + if (!is_file($file)) { + return; + } + + $migration = $this->resolve($file); + $pdo->beginTransaction(); + try { + $migration->down($pdo); + $stmt = $pdo->prepare('DELETE FROM migrations WHERE name = :name'); + $stmt->execute(['name' => $name]); + $pdo->commit(); + } catch (RuntimeException $exception) { + $pdo->rollBack(); + throw $exception; + } + } + + private function resolve(string $file): Migration + { + $migration = require $file; + if (!$migration instanceof Migration) { + throw new RuntimeException(sprintf('Migration file %s must return a Migration instance.', $file)); + } + + return $migration; + } + + private function ensureMigrationsTable(PDO $pdo): void + { + $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + if ($driver === 'pgsql') { + $pdo->exec('CREATE TABLE IF NOT EXISTS migrations (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, ran_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP)'); + } else { + $pdo->exec('CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, ran_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)'); + } + } + + private function hasRun(PDO $pdo, string $name): bool + { + $stmt = $pdo->prepare('SELECT 1 FROM migrations WHERE name = :name'); + $stmt->execute(['name' => $name]); + + return (bool) $stmt->fetchColumn(); + } + + private function markAsRun(PDO $pdo, string $name): void + { + $stmt = $pdo->prepare('INSERT INTO migrations (name) VALUES (:name)'); + $stmt->execute(['name' => $name]); + } +} diff --git a/app/Database/Seeders/DatabaseSeeder.php b/app/Database/Seeders/DatabaseSeeder.php new file mode 100644 index 0000000..0a2b9e9 --- /dev/null +++ b/app/Database/Seeders/DatabaseSeeder.php @@ -0,0 +1,30 @@ + */ + private array $seeders; + + public function __construct() + { + $this->seeders = [ + new PromptSectionSeeder(), + ]; + } + + public function run(?PDO $pdo = null): void + { + $pdo ??= Connection::pdo(); + + foreach ($this->seeders as $seeder) { + $seeder->run($pdo); + } + } +} diff --git a/app/Database/Seeders/PromptSectionSeeder.php b/app/Database/Seeders/PromptSectionSeeder.php new file mode 100644 index 0000000..7a7e1ef --- /dev/null +++ b/app/Database/Seeders/PromptSectionSeeder.php @@ -0,0 +1,60 @@ +> */ + private array $sections = [ + ['key' => 'identity', 'title' => 'Identity & Context', 'description' => 'Defines who AskGVT is, current date awareness, and how it presents itself.', 'order_index' => 1], + ['key' => 'product_info', 'title' => 'Product & Model Information', 'description' => 'Describes AskGVT’s model family, API access, and capabilities.', 'order_index' => 2], + ['key' => 'safety', 'title' => 'Safety, Ethics & Refusal Rules', 'description' => 'Outlines safety principles, refusals, and prohibited content handling.', 'order_index' => 3], + ['key' => 'style_tone', 'title' => 'Tone, Style & Formatting Guidelines', 'description' => 'Controls tone, empathy, style, formatting, and how lists or bullets are used.', 'order_index' => 4], + ['key' => 'knowledge_cutoff', 'title' => 'Knowledge Cutoff & Temporal Awareness', 'description' => 'Specifies AskGVT’s reliable knowledge date and how to handle newer information.', 'order_index' => 5], + ['key' => 'search_instructions', 'title' => 'Search & Tool Usage Instructions', 'description' => 'Defines when and how AskGVT should use search or external tools.', 'order_index' => 6], + ['key' => 'query_complexity', 'title' => 'Query Complexity & Research Categories', 'description' => 'Describes decision rules for single search vs multi-tool research.', 'order_index' => 7], + ['key' => 'web_search_guidelines', 'title' => 'Web Search Behaviour', 'description' => 'Details query formation, result selection, and source prioritisation.', 'order_index' => 8], + ['key' => 'copyright_policy', 'title' => 'Copyright & Legal Requirements', 'description' => 'Sets hard rules for fair use, quoting, and handling of copyrighted material.', 'order_index' => 9], + ['key' => 'harmful_content', 'title' => 'Harmful & Sensitive Content Policies', 'description' => 'Defines how AskGVT handles hate, violence, child safety, and illegal content.', 'order_index' => 10], + ['key' => 'search_examples', 'title' => 'Search & Research Examples', 'description' => 'Provides demonstration examples for how AskGVT should perform searches.', 'order_index' => 11], + ['key' => 'critical_reminders', 'title' => 'Critical Behavioural Reminders', 'description' => 'Key overarching priorities such as respecting copyright and search limits.', 'order_index' => 12], + ['key' => 'citation_instructions', 'title' => 'Citation & Attribution Rules', 'description' => 'How to cite search sources and structure attributions.', 'order_index' => 13], + ['key' => 'artifacts_info', 'title' => 'Artifact Creation & Usage Rules', 'description' => 'Defines when to create artifacts, their allowed types, and design principles.', 'order_index' => 14], + ['key' => 'analysis_tool', 'title' => 'Analysis / REPL Tool Instructions', 'description' => 'Outlines when and how the analysis tool may be used, with supported libraries.', 'order_index' => 15], + ['key' => 'style_system', 'title' => 'User Style System', 'description' => 'Explains how AskGVT handles and tags.', 'order_index' => 16], + ['key' => 'closing', 'title' => 'Final Constraints & Behaviour Summary', 'description' => 'Summarises high-level behavioural constraints and prohibited actions.', 'order_index' => 17], + ]; + + public function run(PDO $pdo): void + { + $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + if ($driver === 'pgsql') { + $sql = 'INSERT INTO prompt_section (key, title, description, order_index, enabled, created_at, updated_at) + VALUES (:key, :title, :description, :order_index, :enabled, :created_at, :updated_at) + ON CONFLICT (key) DO NOTHING'; + } else { + $sql = 'INSERT OR IGNORE INTO prompt_section (key, title, description, order_index, enabled, created_at, updated_at) + VALUES (:key, :title, :description, :order_index, :enabled, :created_at, :updated_at)'; + } + + $stmt = $pdo->prepare($sql); + + foreach ($this->sections as $section) { + $stmt->execute([ + 'key' => $section['key'], + 'title' => $section['title'], + 'description' => $section['description'], + 'order_index' => $section['order_index'], + 'enabled' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } +} diff --git a/app/Database/Seeders/Seeder.php b/app/Database/Seeders/Seeder.php new file mode 100644 index 0000000..b653c1f --- /dev/null +++ b/app/Database/Seeders/Seeder.php @@ -0,0 +1,12 @@ + $location]); + } +} diff --git a/app/Http/Request.php b/app/Http/Request.php new file mode 100644 index 0000000..2c6a8f3 --- /dev/null +++ b/app/Http/Request.php @@ -0,0 +1,73 @@ + $query */ + /** @param array $body */ + /** @param array $server */ + public function __construct( + private array $query, + private array $body, + private array $server, + ) { + } + + public static function capture(): self + { + return new self($_GET, $_POST, $_SERVER); + } + + public function method(): string + { + return strtoupper((string) ($this->server['REQUEST_METHOD'] ?? 'GET')); + } + + public function path(): string + { + $uri = (string) ($this->server['REQUEST_URI'] ?? '/'); + $path = parse_url($uri, PHP_URL_PATH); + + return $path === null ? '/' : ($path === '' ? '/' : $path); + } + + public function isPost(): bool + { + return $this->method() === 'POST'; + } + + public function query(string $key, mixed $default = null): mixed + { + return $this->query[$key] ?? $default; + } + + public function input(string $key, mixed $default = null): mixed + { + return $this->body[$key] ?? $this->query($key, $default); + } + + /** + * @param array $keys + * @return array + */ + public function only(array $keys): array + { + $values = []; + foreach ($keys as $key) { + $values[$key] = $this->input($key); + } + + return $values; + } + + /** + * @return array + */ + public function all(): array + { + return array_merge($this->query, $this->body); + } +} diff --git a/app/Http/Response.php b/app/Http/Response.php new file mode 100644 index 0000000..878b0eb --- /dev/null +++ b/app/Http/Response.php @@ -0,0 +1,36 @@ + $headers */ + public function __construct( + private string $content, + private int $status = 200, + private array $headers = [] + ) { + } + + public static function json(array $data, int $status = 200): self + { + return new self(json_encode($data, JSON_PRETTY_PRINT), $status, ['Content-Type' => 'application/json']); + } + + public function send(): void + { + http_response_code($this->status); + foreach ($this->headers as $key => $value) { + header(sprintf('%s: %s', $key, $value)); + } + + echo $this->content; + } + + public function content(): string + { + return $this->content; + } +} diff --git a/app/Repositories/PromptLineRepository.php b/app/Repositories/PromptLineRepository.php new file mode 100644 index 0000000..3ed3fc5 --- /dev/null +++ b/app/Repositories/PromptLineRepository.php @@ -0,0 +1,173 @@ +pdo = Connection::pdo(); + } + + /** + * @return array> + */ + public function forVersion(int $versionId, ?int $sectionId = null, bool $includeDisabled = true): array + { + $sql = 'SELECT l.*, s.key as section_key, s.title as section_title, s.order_index as section_order + FROM prompt_line l + INNER JOIN prompt_section s ON s.id = l.section_id + WHERE l.version_id = :version_id'; + $params = ['version_id' => $versionId]; + + if ($sectionId !== null) { + $sql .= ' AND l.section_id = :section_id'; + $params['section_id'] = $sectionId; + } + + if (!$includeDisabled) { + $sql .= ' AND l.enabled = 1 AND s.enabled = 1'; + } + + $sql .= ' ORDER BY s.order_index ASC, l.order_index ASC, l.id ASC'; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + + return $stmt->fetchAll() ?: []; + } + + public function find(int $id): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM prompt_line WHERE id = :id'); + $stmt->execute(['id' => $id]); + $line = $stmt->fetch(); + + return $line ?: null; + } + + /** + * @param array $attributes + */ + public function create(array $attributes): int + { + $order = $attributes['order_index'] ?? $this->nextOrder((int) $attributes['version_id']); + $now = $this->now(); + $stmt = $this->pdo->prepare('INSERT INTO prompt_line (version_id, section_id, order_index, enabled, content, created_at, updated_at) + VALUES (:version_id, :section_id, :order_index, :enabled, :content, :created_at, :updated_at)'); + $stmt->execute([ + 'version_id' => (int) $attributes['version_id'], + 'section_id' => (int) $attributes['section_id'], + 'order_index' => (int) $order, + 'enabled' => !empty($attributes['enabled']) ? 1 : 0, + 'content' => $attributes['content'], + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return (int) $this->pdo->lastInsertId(); + } + + /** + * @param array $attributes + */ + public function update(int $id, array $attributes): void + { + $stmt = $this->pdo->prepare('UPDATE prompt_line SET section_id = :section_id, order_index = :order_index, enabled = :enabled, content = :content, updated_at = :updated_at WHERE id = :id'); + $stmt->execute([ + 'id' => $id, + 'section_id' => (int) $attributes['section_id'], + 'order_index' => (int) ($attributes['order_index'] ?? 0), + 'enabled' => !empty($attributes['enabled']) ? 1 : 0, + 'content' => $attributes['content'], + 'updated_at' => $this->now(), + ]); + } + + public function delete(int $id): void + { + $stmt = $this->pdo->prepare('DELETE FROM prompt_line WHERE id = :id'); + $stmt->execute(['id' => $id]); + } + + public function move(int $id, int $direction): void + { + $line = $this->find($id); + if (!$line) { + throw new RuntimeException('Line not found.'); + } + + $operator = $direction < 0 ? '<' : '>'; + $order = (int) $line['order_index']; + $comparison = $direction < 0 ? 'DESC' : 'ASC'; + $stmt = $this->pdo->prepare("SELECT * FROM prompt_line WHERE version_id = :version_id AND order_index {$operator} :order_index ORDER BY order_index {$comparison} LIMIT 1"); + $stmt->execute([ + 'version_id' => $line['version_id'], + 'order_index' => $order, + ]); + $swap = $stmt->fetch(); + + if (!$swap) { + return; + } + + $this->pdo->beginTransaction(); + try { + $this->updateOrder((int) $line['id'], (int) $swap['order_index']); + $this->updateOrder((int) $swap['id'], $order); + $this->pdo->commit(); + } catch (RuntimeException $exception) { + $this->pdo->rollBack(); + throw $exception; + } + } + + public function resequence(int $versionId): void + { + $stmt = $this->pdo->prepare('SELECT id FROM prompt_line WHERE version_id = :version_id ORDER BY order_index ASC, id ASC'); + $stmt->execute(['version_id' => $versionId]); + $ids = $stmt->fetchAll(PDO::FETCH_COLUMN); + + $order = 1; + $update = $this->pdo->prepare('UPDATE prompt_line SET order_index = :order_index, updated_at = :updated_at WHERE id = :id'); + foreach ($ids as $id) { + $update->execute([ + 'order_index' => $order++, + 'updated_at' => $this->now(), + 'id' => $id, + ]); + } + } + + private function updateOrder(int $id, int $order): void + { + $stmt = $this->pdo->prepare('UPDATE prompt_line SET order_index = :order_index, updated_at = :updated_at WHERE id = :id'); + $stmt->execute([ + 'id' => $id, + 'order_index' => $order, + 'updated_at' => $this->now(), + ]); + } + + private function nextOrder(int $versionId): int + { + $stmt = $this->pdo->prepare('SELECT MAX(order_index) FROM prompt_line WHERE version_id = :version_id'); + $stmt->execute(['version_id' => $versionId]); + $max = $stmt->fetchColumn(); + + return ((int) $max) + 1; + } + + private function now(): string + { + return (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + } +} diff --git a/app/Repositories/PromptSectionRepository.php b/app/Repositories/PromptSectionRepository.php new file mode 100644 index 0000000..2774fac --- /dev/null +++ b/app/Repositories/PromptSectionRepository.php @@ -0,0 +1,88 @@ +pdo = Connection::pdo(); + } + + /** + * @return array> + */ + public function all(): array + { + $stmt = $this->pdo->query('SELECT * FROM prompt_section ORDER BY order_index ASC, id ASC'); + + return $stmt->fetchAll() ?: []; + } + + public function find(int $id): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM prompt_section WHERE id = :id'); + $stmt->execute(['id' => $id]); + $section = $stmt->fetch(); + + return $section ?: null; + } + + public function findByKey(string $key): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM prompt_section WHERE key = :key'); + $stmt->execute(['key' => $key]); + $section = $stmt->fetch(); + + return $section ?: null; + } + + /** + * @param array $attributes + */ + public function create(array $attributes): int + { + $now = $this->now(); + $stmt = $this->pdo->prepare('INSERT INTO prompt_section (key, title, description, order_index, enabled, created_at, updated_at) + VALUES (:key, :title, :description, :order_index, :enabled, :created_at, :updated_at)'); + $stmt->execute([ + 'key' => $attributes['key'], + 'title' => $attributes['title'] ?? null, + 'description' => $attributes['description'] ?? null, + 'order_index' => (int) ($attributes['order_index'] ?? 0), + 'enabled' => !empty($attributes['enabled']) ? 1 : 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return (int) $this->pdo->lastInsertId(); + } + + /** + * @param array $attributes + */ + public function update(int $id, array $attributes): void + { + $stmt = $this->pdo->prepare('UPDATE prompt_section SET title = :title, description = :description, order_index = :order_index, enabled = :enabled, updated_at = :updated_at WHERE id = :id'); + $stmt->execute([ + 'id' => $id, + 'title' => $attributes['title'] ?? null, + 'description' => $attributes['description'] ?? null, + 'order_index' => (int) ($attributes['order_index'] ?? 0), + 'enabled' => !empty($attributes['enabled']) ? 1 : 0, + 'updated_at' => $this->now(), + ]); + } + + private function now(): string + { + return (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + } +} diff --git a/app/Repositories/PromptVersionRepository.php b/app/Repositories/PromptVersionRepository.php new file mode 100644 index 0000000..c82085d --- /dev/null +++ b/app/Repositories/PromptVersionRepository.php @@ -0,0 +1,138 @@ +pdo = Connection::pdo(); + } + + /** + * @return array> + */ + public function all(): array + { + $stmt = $this->pdo->query('SELECT * FROM prompt_version ORDER BY created_at DESC, id DESC'); + + return $stmt->fetchAll() ?: []; + } + + public function find(int $id): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM prompt_version WHERE id = :id'); + $stmt->execute(['id' => $id]); + $version = $stmt->fetch(); + + return $version ?: null; + } + + /** + * @param array $attributes + */ + public function create(array $attributes): int + { + $status = $attributes['status']; + if (!in_array($status, self::STATUSES, true)) { + throw new RuntimeException('Invalid status value.'); + } + + $now = $this->now(); + $stmt = $this->pdo->prepare('INSERT INTO prompt_version (prompt_name, version_label, status, notes, created_at, updated_at) + VALUES (:prompt_name, :version_label, :status, :notes, :created_at, :updated_at)'); + $stmt->execute([ + 'prompt_name' => $attributes['prompt_name'], + 'version_label' => $attributes['version_label'], + 'status' => $status, + 'notes' => $attributes['notes'] ?? null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return (int) $this->pdo->lastInsertId(); + } + + /** + * @param array $attributes + */ + public function update(int $id, array $attributes): void + { + $status = $attributes['status']; + if (!in_array($status, self::STATUSES, true)) { + throw new RuntimeException('Invalid status value.'); + } + + $stmt = $this->pdo->prepare('UPDATE prompt_version SET prompt_name = :prompt_name, version_label = :version_label, status = :status, notes = :notes, updated_at = :updated_at WHERE id = :id'); + $stmt->execute([ + 'id' => $id, + 'prompt_name' => $attributes['prompt_name'], + 'version_label' => $attributes['version_label'], + 'status' => $status, + 'notes' => $attributes['notes'] ?? null, + 'updated_at' => $this->now(), + ]); + } + + public function delete(int $id): void + { + $stmt = $this->pdo->prepare('DELETE FROM prompt_version WHERE id = :id'); + $stmt->execute(['id' => $id]); + } + + public function setStatus(int $id, string $status): void + { + if (!in_array($status, self::STATUSES, true)) { + throw new RuntimeException('Invalid status value.'); + } + + $stmt = $this->pdo->prepare('UPDATE prompt_version SET status = :status, updated_at = :updated_at WHERE id = :id'); + $stmt->execute([ + 'id' => $id, + 'status' => $status, + 'updated_at' => $this->now(), + ]); + } + + public function duplicate(int $id, string $newLabel): int + { + $original = $this->find($id); + if (!$original) { + throw new RuntimeException('Version not found.'); + } + + $copyId = $this->create([ + 'prompt_name' => $original['prompt_name'], + 'version_label' => $newLabel, + 'status' => 'draft', + 'notes' => $original['notes'], + ]); + + $this->pdo->prepare('INSERT INTO prompt_line (version_id, section_id, order_index, enabled, content, created_at, updated_at) + SELECT :new_version_id, section_id, order_index, enabled, content, :created_at, :updated_at + FROM prompt_line WHERE version_id = :original_version_id') + ->execute([ + 'new_version_id' => $copyId, + 'original_version_id' => $id, + 'created_at' => $this->now(), + 'updated_at' => $this->now(), + ]); + + return $copyId; + } + + private function now(): string + { + return (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + } +} diff --git a/app/Routing/Router.php b/app/Routing/Router.php new file mode 100644 index 0000000..9065e3f --- /dev/null +++ b/app/Routing/Router.php @@ -0,0 +1,73 @@ + */ + private array $routes = []; + + public function get(string $pattern, callable $handler): void + { + $this->addRoute('GET', $pattern, $handler); + } + + public function post(string $pattern, callable $handler): void + { + $this->addRoute('POST', $pattern, $handler); + } + + private function addRoute(string $method, string $pattern, callable $handler): void + { + $this->routes[] = compact('method', 'pattern', 'handler'); + } + + public function dispatch(Request $request): Response + { + $path = rtrim($request->path(), '/') ?: '/'; + $method = $request->method(); + + foreach ($this->routes as $route) { + if ($route['method'] !== $method) { + continue; + } + + $regex = $this->toRegex($route['pattern']); + if (preg_match($regex, $path, $matches)) { + $params = array_filter( + $matches, + fn($key) => !is_int($key), + ARRAY_FILTER_USE_KEY + ); + $response = ($route['handler'])($request, $params); + + if ($response instanceof Response) { + return $response; + } + + if (is_string($response)) { + return new Response($response); + } + + throw new RuntimeException('Route handler must return a Response or string.'); + } + } + + return new Response('Not Found', 404); + } + + private function toRegex(string $pattern): string + { + $pattern = rtrim($pattern, '/') ?: '/'; + $escaped = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $pattern); + $escaped = str_replace('/', '\/', $escaped ?? $pattern); + + return '#^' . $escaped . '$#'; + } +} diff --git a/app/Services/PromptRenderer.php b/app/Services/PromptRenderer.php new file mode 100644 index 0000000..95b233c --- /dev/null +++ b/app/Services/PromptRenderer.php @@ -0,0 +1,87 @@ + + */ + public function structuredPrompt(int $versionId): array + { + $version = $this->versions->find($versionId); + if (!$version) { + throw new RuntimeException('Prompt version not found.'); + } + + $sections = $this->sections->all(); + $lines = $this->lines->forVersion($versionId, null, false); + + $grouped = []; + foreach ($sections as $section) { + if ((int) $section['enabled'] !== 1) { + continue; + } + + $grouped[$section['id']] = [ + 'section' => $section, + 'lines' => [], + ]; + } + + foreach ($lines as $line) { + $sectionId = (int) $line['section_id']; + if (!isset($grouped[$sectionId])) { + continue; + } + + $grouped[$sectionId]['lines'][] = $line; + } + + return [ + 'version' => $version, + 'sections' => array_values($grouped), + ]; + } + + public function renderText(int $versionId): string + { + $prompt = $this->structuredPrompt($versionId); + $lines = []; + + foreach ($prompt['sections'] as $bundle) { + $section = $bundle['section']; + $sectionLines = $bundle['lines']; + if (empty($sectionLines)) { + continue; + } + + $lines[] = '### ' . $section['title']; + if (!empty($section['description'])) { + $lines[] = trim((string) $section['description']); + } + + foreach ($sectionLines as $line) { + $lines[] = trim((string) $line['content']); + } + + $lines[] = ''; + } + + return trim(implode(PHP_EOL, $lines)); + } +} diff --git a/app/Support/Env.php b/app/Support/Env.php new file mode 100644 index 0000000..dd074ac --- /dev/null +++ b/app/Support/Env.php @@ -0,0 +1,41 @@ + */ + private static array $values = []; + + public static function load(string $path): void + { + self::$values = []; + + if (!is_file($path)) { + return; + } + + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return; + } + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + [$name, $value] = array_map('trim', explode('=', $line, 2) + ['', '']); + $value = trim($value, "\"' "); + self::$values[$name] = $value; + } + } + + public static function get(string $key, mixed $default = null): mixed + { + return self::$values[$key] ?? $default; + } +} diff --git a/app/View/View.php b/app/View/View.php new file mode 100644 index 0000000..60e6aad --- /dev/null +++ b/app/View/View.php @@ -0,0 +1,33 @@ + $data + */ + public function render(string $template, array $data = []): Response + { + $path = rtrim($this->basePath, '/') . '/' . $template . '.php'; + if (!is_file($path)) { + throw new RuntimeException(sprintf('View [%s] not found.', $template)); + } + + extract($data, EXTR_SKIP); + ob_start(); + include $path; + $content = (string) ob_get_clean(); + + return new Response($content); + } +} diff --git a/artisan b/artisan new file mode 100644 index 0000000..ce42a10 --- /dev/null +++ b/artisan @@ -0,0 +1,75 @@ +#!/usr/bin/env php + (function () use ($options): void { + $migrator = new Migrator(BASE_PATH . '/database/migrations'); + $migrator->migrate(); + output('Migrations executed successfully.'); + + if (in_array('--seed', $options, true)) { + $seeder = new DatabaseSeeder(); + $seeder->run(); + output('Database seeded successfully.'); + } + })(), + 'migrate:rollback' => (function (): void { + $migrator = new Migrator(BASE_PATH . '/database/migrations'); + $migrator->rollbackLast(); + output('Rolled back the last migration.'); + })(), + 'seed' => (function (): void { + $seeder = new DatabaseSeeder(); + $seeder->run(); + output('Database seeded successfully.'); + })(), + 'test' => (function () use ($options): void { + // allow "--fresh" to rebuild database before running tests + if (in_array('--fresh', $options, true)) { + Connection::override(['driver' => 'sqlite', 'database' => ':memory:']); + $migrator = new Migrator(BASE_PATH . '/database/migrations'); + $migrator->migrate(); + $seeder = new DatabaseSeeder(); + $seeder->run(); + } + + require BASE_PATH . '/tests/run.php'; + })(), + null, 'help', '--help', '-h' => (function (): void { + output('Available commands:'); + output(' migrate [--seed] Run the database migrations'); + output(' migrate:rollback Roll back the last migration'); + output(' seed Seed the database with initial data'); + output(' test [--fresh] Execute the test suite'); + })(), + default => (function (string $unknown): void { + error("Unknown command: {$unknown}"); + })($command), + }; +} catch (Throwable $exception) { + error('Error: ' . $exception->getMessage()); + exit(1); +} diff --git a/bootstrap/autoload.php b/bootstrap/autoload.php new file mode 100644 index 0000000..28b711c --- /dev/null +++ b/bootstrap/autoload.php @@ -0,0 +1,27 @@ + $data + */ + function view(string $template, array $data = []): App\Http\Response + { + $factory = new App\View\View(BASE_PATH . '/resources/views'); + + return $factory->render($template, $data); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..89e364b --- /dev/null +++ b/composer.json @@ -0,0 +1,16 @@ +{ + "name": "askgvt/prompt-editor", + "description": "Prompt management system for AskGVT", + "type": "project", + "require": { + "php": "^8.2" + }, + "autoload": { + "psr-4": { + "App\\\\": "app/" + } + }, + "scripts": { + "test": "php artisan test" + } +} diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..52c055b --- /dev/null +++ b/config/database.php @@ -0,0 +1,21 @@ + env('DB_CONNECTION', 'sqlite'), + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => env('DB_DATABASE', BASE_PATH . '/database/database.sqlite'), + ], + 'pgsql' => [ + 'driver' => 'pgsql', + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'askgvt'), + 'username' => env('DB_USERNAME', 'postgres'), + 'password' => env('DB_PASSWORD', ''), + ], + ], +]; diff --git a/database/migrations/001_create_prompt_tables.php b/database/migrations/001_create_prompt_tables.php new file mode 100644 index 0000000..f029d5e --- /dev/null +++ b/database/migrations/001_create_prompt_tables.php @@ -0,0 +1,86 @@ +getAttribute(\PDO::ATTR_DRIVER_NAME); + if ($driver === 'pgsql') { + $pdo->exec('CREATE TABLE IF NOT EXISTS prompt_version ( + id BIGSERIAL PRIMARY KEY, + prompt_name TEXT NOT NULL, + version_label TEXT NOT NULL, + status TEXT NOT NULL, + notes TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + )'); + + $pdo->exec('CREATE TABLE IF NOT EXISTS prompt_section ( + id BIGSERIAL PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + title TEXT NULL, + description TEXT NULL, + order_index INTEGER NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + )'); + + $pdo->exec('CREATE TABLE IF NOT EXISTS prompt_line ( + id BIGSERIAL PRIMARY KEY, + version_id BIGINT NOT NULL REFERENCES prompt_version(id) ON DELETE CASCADE, + section_id BIGINT NOT NULL REFERENCES prompt_section(id) ON DELETE RESTRICT, + order_index INTEGER NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + )'); + } else { + $pdo->exec('CREATE TABLE IF NOT EXISTS prompt_version ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt_name TEXT NOT NULL, + version_label TEXT NOT NULL, + status TEXT NOT NULL, + notes TEXT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )'); + + $pdo->exec('CREATE TABLE IF NOT EXISTS prompt_section ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + title TEXT NULL, + description TEXT NULL, + order_index INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + )'); + + $pdo->exec('CREATE TABLE IF NOT EXISTS prompt_line ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL, + section_id INTEGER NOT NULL, + order_index INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(version_id) REFERENCES prompt_version(id) ON DELETE CASCADE, + FOREIGN KEY(section_id) REFERENCES prompt_section(id) ON DELETE RESTRICT + )'); + } + } + + public function down(\PDO $pdo): void + { + $pdo->exec('DROP TABLE IF EXISTS prompt_line'); + $pdo->exec('DROP TABLE IF EXISTS prompt_section'); + $pdo->exec('DROP TABLE IF EXISTS prompt_version'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..59f0c44 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,15 @@ +call([ + PromptSectionSeeder::class, + ]); + } +} diff --git a/database/seeders/PromptSectionSeeder.php b/database/seeders/PromptSectionSeeder.php new file mode 100644 index 0000000..fec69e1 --- /dev/null +++ b/database/seeders/PromptSectionSeeder.php @@ -0,0 +1,36 @@ + 'identity', 'title' => 'Identity & Context', 'description' => 'Defines who AskGVT is, current date awareness, and how it presents itself.', 'order_index' => 1], + ['key' => 'product_info', 'title' => 'Product & Model Information', 'description' => 'Describes AskGVT’s model family, API access, and capabilities.', 'order_index' => 2], + ['key' => 'safety', 'title' => 'Safety, Ethics & Refusal Rules', 'description' => 'Outlines safety principles, refusals, and prohibited content handling.', 'order_index' => 3], + ['key' => 'style_tone', 'title' => 'Tone, Style & Formatting Guidelines', 'description' => 'Controls tone, empathy, style, formatting, and how lists or bullets are used.', 'order_index' => 4], + ['key' => 'knowledge_cutoff', 'title' => 'Knowledge Cutoff & Temporal Awareness', 'description' => 'Specifies AskGVT’s reliable knowledge date and how to handle newer information.', 'order_index' => 5], + ['key' => 'search_instructions', 'title' => 'Search & Tool Usage Instructions', 'description' => 'Defines when and how AskGVT should use search or external tools.', 'order_index' => 6], + ['key' => 'query_complexity', 'title' => 'Query Complexity & Research Categories', 'description' => 'Describes decision rules for single search vs multi-tool research.', 'order_index' => 7], + ['key' => 'web_search_guidelines', 'title' => 'Web Search Behaviour', 'description' => 'Details query formation, result selection, and source prioritisation.', 'order_index' => 8], + ['key' => 'copyright_policy', 'title' => 'Copyright & Legal Requirements', 'description' => 'Sets hard rules for fair use, quoting, and handling of copyrighted material.', 'order_index' => 9], + ['key' => 'harmful_content', 'title' => 'Harmful & Sensitive Content Policies', 'description' => 'Defines how AskGVT handles hate, violence, child safety, and illegal content.', 'order_index' => 10], + ['key' => 'search_examples', 'title' => 'Search & Research Examples', 'description' => 'Provides demonstration examples for how AskGVT should perform searches.', 'order_index' => 11], + ['key' => 'critical_reminders', 'title' => 'Critical Behavioural Reminders', 'description' => 'Key overarching priorities such as respecting copyright and search limits.', 'order_index' => 12], + ['key' => 'citation_instructions', 'title' => 'Citation & Attribution Rules', 'description' => 'How to cite search sources and structure attributions.', 'order_index' => 13], + ['key' => 'artifacts_info', 'title' => 'Artifact Creation & Usage Rules', 'description' => 'Defines when to create artifacts, their allowed types, and design principles.', 'order_index' => 14], + ['key' => 'analysis_tool', 'title' => 'Analysis / REPL Tool Instructions', 'description' => 'Outlines when and how the analysis tool may be used, with supported libraries.', 'order_index' => 15], + ['key' => 'style_system', 'title' => 'User Style System', 'description' => 'Explains how AskGVT handles and tags.', 'order_index' => 16], + ['key' => 'closing', 'title' => 'Final Constraints & Behaviour Summary', 'description' => 'Summarises high-level behavioural constraints and prohibited actions.', 'order_index' => 17], + ]; + + foreach ($sections as $section) { + PromptSection::updateOrCreate(['key' => $section['key']], $section); + } + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..bb662da --- /dev/null +++ b/public/index.php @@ -0,0 +1,32 @@ +dispatch($request); +$response->send(); diff --git a/readme.MD b/readme.MD index 40b38ee..d9fd828 100644 --- a/readme.MD +++ b/readme.MD @@ -1 +1,31 @@ -first test \ No newline at end of file +# AskGVT Prompt Editor + +A lightweight PHP prompt management tool for editing and versioning AskGVT system prompts without relying on external Composer packages. + +## Setup + +1. Copy `.env.example` to `.env` and adjust the database settings. SQLite is used by default and requires no additional configuration. +2. Run the database migrations and seed the default prompt sections: + ```bash + php artisan migrate --seed + ``` +3. (Optional) Execute the built-in test suite: + ```bash + php artisan test --fresh + ``` +4. Serve the application using PHP's built-in web server: + ```bash + php -S localhost:8000 -t public + ``` + +## Features + +- CRUD management for prompt versions, sections, and lines via a simple web UI +- Section-based filtering and ordering controls for prompt lines +- Duplicate prompt versions to iterate on variants quickly +- API endpoint (`GET /api/prompts/{id}`) that returns the rendered prompt text with metadata +- Standalone artisan-style CLI for migrations, seeding, and tests + +## Rendering + +The renderer groups enabled sections and lines, producing a markdown-friendly prompt body that downstream services can consume directly. diff --git a/resources/views/lines/form.php b/resources/views/lines/form.php new file mode 100644 index 0000000..0aae00b --- /dev/null +++ b/resources/views/lines/form.php @@ -0,0 +1,31 @@ + + +

+ +
+ + +
+ + + +
+ + + +
+ + + + + +
+ + diff --git a/resources/views/partials/footer.php b/resources/views/partials/footer.php new file mode 100644 index 0000000..01dda28 --- /dev/null +++ b/resources/views/partials/footer.php @@ -0,0 +1,6 @@ + +
+ © AskGVT Prompt Management +
+ + diff --git a/resources/views/partials/header.php b/resources/views/partials/header.php new file mode 100644 index 0000000..a3ff381 --- /dev/null +++ b/resources/views/partials/header.php @@ -0,0 +1,41 @@ + + + + + + <?= htmlspecialchars($title ?? 'Prompt Editor', ENT_QUOTES) ?> + + + +
+

AskGVT Prompt Editor

+ +
+
diff --git a/resources/views/sections/form.php b/resources/views/sections/form.php new file mode 100644 index 0000000..228183c --- /dev/null +++ b/resources/views/sections/form.php @@ -0,0 +1,25 @@ + + +
+ + > +
+ + + +
+ + + + + + +
+ + + + + +
+ + diff --git a/resources/views/sections/index.php b/resources/views/sections/index.php new file mode 100644 index 0000000..3337df0 --- /dev/null +++ b/resources/views/sections/index.php @@ -0,0 +1,38 @@ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
KeyTitleDescriptionOrderEnabledActions
+ +
+ + diff --git a/resources/views/versions/form.php b/resources/views/versions/form.php new file mode 100644 index 0000000..9bcf4c5 --- /dev/null +++ b/resources/views/versions/form.php @@ -0,0 +1,33 @@ + + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+ + +
+ + +
+ + diff --git a/resources/views/versions/index.php b/resources/views/versions/index.php new file mode 100644 index 0000000..4abb132 --- /dev/null +++ b/resources/views/versions/index.php @@ -0,0 +1,44 @@ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NameLabelStatusUpdatedNotesActions
+
+ + +
+ +
+
+
+ + diff --git a/resources/views/versions/show.php b/resources/views/versions/show.php new file mode 100644 index 0000000..a5acea6 --- /dev/null +++ b/resources/views/versions/show.php @@ -0,0 +1,90 @@ + + +

+

Status:

+ +
+
+ + + +
+ +
+ + + +
+
+ + + +
+ + + +
+ + +
+ + +'; + endif; + $currentSection = $line['section_id']; + ?> +
+

+ +

Order:

+ + +
+

#

+

Status:

+
+
+ + +
+
+ + +
+ +
+ +
+
+
+ + +
+ + + +

No prompt lines for this version yet.

+ + + diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..139eb47 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,43 @@ +get('/', function () { + return new RedirectResponse('/versions'); +}); + +$router->get('/versions', fn (Request $request) => $promptVersionController->index($request)); +$router->get('/versions/create', fn (Request $request) => $promptVersionController->create($request)); +$router->post('/versions/create', fn (Request $request) => $promptVersionController->store($request)); +$router->get('/versions/{id}', fn (Request $request, array $params) => $promptVersionController->show($request, $params)); +$router->get('/versions/{id}/edit', fn (Request $request, array $params) => $promptVersionController->edit($request, $params)); +$router->post('/versions/{id}/edit', fn (Request $request, array $params) => $promptVersionController->update($request, $params)); +$router->post('/versions/{id}/delete', fn (Request $request, array $params) => $promptVersionController->destroy($request, $params)); +$router->post('/versions/{id}/status', fn (Request $request, array $params) => $promptVersionController->updateStatus($request, $params)); +$router->post('/versions/{id}/duplicate', fn (Request $request, array $params) => $promptVersionController->duplicate($request, $params)); +$router->get('/api/prompts/{id}', fn (Request $request, array $params) => $promptVersionController->renderPrompt($request, $params)); + +$router->get('/sections', fn (Request $request) => $promptSectionController->index($request)); +$router->get('/sections/create', fn (Request $request) => $promptSectionController->create($request)); +$router->post('/sections/create', fn (Request $request) => $promptSectionController->store($request)); +$router->get('/sections/{id}/edit', fn (Request $request, array $params) => $promptSectionController->edit($request, $params)); +$router->post('/sections/{id}/edit', fn (Request $request, array $params) => $promptSectionController->update($request, $params)); + +$router->get('/versions/{version}/lines/create', fn (Request $request, array $params) => $promptLineController->create($request, $params)); +$router->post('/versions/{version}/lines/create', fn (Request $request, array $params) => $promptLineController->store($request, $params)); +$router->get('/lines/{id}/edit', fn (Request $request, array $params) => $promptLineController->edit($request, $params)); +$router->post('/lines/{id}/edit', fn (Request $request, array $params) => $promptLineController->update($request, $params)); +$router->post('/lines/{id}/delete', fn (Request $request, array $params) => $promptLineController->destroy($request, $params)); +$router->post('/lines/{id}/move', fn (Request $request, array $params) => $promptLineController->move($request, $params)); diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..b88c777 --- /dev/null +++ b/tests/run.php @@ -0,0 +1,89 @@ + 'sqlite', 'database' => ':memory:']); + +$migrator = new Migrator(BASE_PATH . '/database/migrations'); +$migrator->migrate(); +$seeder = new DatabaseSeeder(); +$seeder->run(); + +$versions = new PromptVersionRepository(); +$sections = new PromptSectionRepository(); +$lines = new PromptLineRepository(); +$renderer = new PromptRenderer($versions, $sections, $lines); + +$identity = $sections->findByKey('identity'); +$style = $sections->findByKey('style_tone'); +assertNotNull($identity, 'Identity section should exist after seeding.'); +assertNotNull($style, 'Style section should exist after seeding.'); + +$versionId = $versions->create([ + 'prompt_name' => 'AskGVT', + 'version_label' => 'v1-test', + 'status' => 'draft', + 'notes' => 'Test version', +]); + +$lines->create([ + 'version_id' => $versionId, + 'section_id' => $identity['id'], + 'content' => 'You are AskGVT, a helpful AI.', + 'enabled' => 1, +]); +$lines->create([ + 'version_id' => $versionId, + 'section_id' => $style['id'], + 'content' => 'Use a friendly, professional tone.', + 'enabled' => 1, +]); + +$output = $renderer->renderText($versionId); +assertTrue(str_contains($output, '### Identity'), 'Rendered prompt should include section headings.'); +assertTrue(str_contains($output, 'friendly, professional tone'), 'Rendered prompt should include line content.'); + +$allVersions = $versions->all(); +assertTrue(count($allVersions) === 1, 'Exactly one version should exist.'); + +$versions->setStatus($versionId, 'active'); +$updated = $versions->find($versionId); +assertTrue($updated['status'] === 'active', 'Status should update to active.'); + +$linesList = $lines->forVersion($versionId, null, true); +assertTrue(count($linesList) === 2, 'Version should have two lines.'); + +$lines->move($linesList[1]['id'], -1); +$movedLine = $lines->find($linesList[1]['id']); +assertTrue((int) $movedLine['order_index'] === 1, 'Move should update ordering.'); +$linesAfterMove = $lines->forVersion($versionId, null, true); + +$lines->delete($linesAfterMove[0]['id']); +$lines->resequence($versionId); +assertTrue(count($lines->forVersion($versionId, null, true)) === 1, 'Deleting a line should reduce count.'); + +echo "All tests passed" . PHP_EOL; + +function assertTrue(bool $condition, string $message): void +{ + if (!$condition) { + throw new RuntimeException('Assertion failed: ' . $message); + } +} + +function assertNotNull(mixed $value, string $message): void +{ + if ($value === null) { + throw new RuntimeException('Assertion failed: ' . $message); + } +}