diff --git a/README.md b/README.md index e480f9c..46b40cd 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,32 @@ $response->results; // [['name' => 'Lowell Boyer', 'total_spent' => 3930.15], .. ## How It Works -1. **Context Assembly** — Before the LLM sees the question, the agent retrieves relevant table metadata, business rules, similar query patterns, and past learnings. This assembled context is injected into the system prompt. -2. **Agentic Tool Loop** — The LLM enters a tool-calling loop where it can introspect live schema, search for additional knowledge, execute SQL, and refine results iteratively. -3. **Self-Learning** — When queries fail and the agent recovers, it saves what it learned. When queries succeed, it saves them as reusable patterns. Both feed back into step 1 for future queries. +```mermaid +flowchart TD + A[User Question] --> B[Retrieve Knowledge + Learnings] + B --> C[Reason about intent] + C --> D[Generate grounded SQL] + D --> E[Execute and interpret] + E --> F{Result} + F -->|Success| G[Return insight] + F -->|Error| H[Diagnose & Fix] + H --> I[Save Learning] + I --> D + G --> J[Optionally save as Knowledge] +``` + +The agent uses six context layers to ground its SQL generation: + +| # | Layer | What it contains | Source | +|---|-------|-----------------|--------| +| 1 | Table Usage | Schema, columns, relationships | `knowledge/tables/*.json` | +| 2 | Human Annotations | Metrics, definitions, business rules | `knowledge/business/*.json` | +| 3 | Query Patterns | SQL known to work | `knowledge/queries/*.json` and `*.sql` | +| 4 | Learnings | Error patterns and discovered fixes | `save_learning` tool (on-demand) | +| 5 | Runtime Context | Live schema inspection | `introspect_schema` tool (on-demand) | +| 6 | Institutional Knowledge | Docs, wikis, external references | Custom tools (`agent.tools` config) | + +Layers 1–3 are loaded from the knowledge base into the system prompt. Layer 4 is built up over time as the agent learns from errors. Layers 5 and 6 are available on-demand — the LLM calls them during the tool loop when it needs live schema details or external context. ## Features diff --git a/composer.json b/composer.json index 3cb4b39..47f91da 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ }, "suggest": { "livewire/livewire": "Required for the chat UI", - "pgvector/pgvector": "Required for the pgvector search driver (vector similarity search)" + "pgvector/pgvector": "Required for the pgvector search driver (vector similarity search)", + "prism-php/relay": "Required for MCP server tool integration (relay config)" }, "autoload": { "psr-4": { diff --git a/config/sql-agent.php b/config/sql-agent.php index dc3001d..40e9946 100644 --- a/config/sql-agent.php +++ b/config/sql-agent.php @@ -170,6 +170,10 @@ // Custom tool class names resolved from the container, e.g.: // [App\SqlAgent\MyCustomTool::class] 'tools' => [], + + // MCP server names (from config/relay.php) whose tools should be + // available to the agent. Requires prism-php/relay to be installed. + 'relay' => [], ], /* @@ -193,20 +197,15 @@ | | Configure knowledge base settings. | - | Source options: - | - 'database': Reads knowledge from the sql_agent_table_metadata, - | sql_agent_business_rules, and sql_agent_query_patterns database tables. - | Requires running `php artisan sql-agent:load-knowledge` first to import - | JSON files into the database. Supports full-text search and is the - | recommended option for production. - | - 'files': Reads knowledge directly from JSON files on disk at the - | configured path. No database import needed, but does not support - | full-text search over knowledge. + | The path option sets the directory containing your JSON knowledge files. + | This path is used by the `sql-agent:load-knowledge` command to import + | files into the database. Knowledge is always read from the database at + | runtime — run `php artisan sql-agent:load-knowledge` after creating or + | changing knowledge files. | */ 'knowledge' => [ 'path' => env('SQL_AGENT_KNOWLEDGE_PATH', resource_path('sql-agent/knowledge')), - 'source' => env('SQL_AGENT_KNOWLEDGE_SOURCE', 'database'), ], /* diff --git a/docs/src/content/docs/getting-started/introduction.md b/docs/src/content/docs/getting-started/introduction.md index 6d29e9f..3b935e2 100644 --- a/docs/src/content/docs/getting-started/introduction.md +++ b/docs/src/content/docs/getting-started/introduction.md @@ -15,15 +15,18 @@ SqlAgent converts questions to SQL through multi-layer context assembly, an agen ### Context Assembly -Before the LLM sees the question, the `ContextBuilder` retrieves and assembles five context layers into the system prompt: - -- **Semantic model** — Table metadata, column descriptions, and relationships from your [Knowledge Base](/sql-agent/guides/knowledge-base/) -- **Business rules** — Metrics definitions, domain rules, and common gotchas -- **Similar query patterns** — Previously validated queries that match the current question, retrieved via the active [search driver](/sql-agent/guides/drivers/) -- **Relevant learnings** — Patterns the agent discovered from past errors and corrections -- **Runtime schema** — Live database introspection for the tables most likely relevant to the question - -This happens automatically on every query — no manual prompt engineering required. +Before the LLM sees the question, the `ContextBuilder` retrieves and assembles six context layers: + +| # | Layer | What it contains | Source | +|---|-------|-----------------|--------| +| 1 | Table Usage | Schema, columns, relationships | `knowledge/tables/*.json` | +| 2 | Human Annotations | Metrics, definitions, business rules | `knowledge/business/*.json` | +| 3 | Query Patterns | SQL known to work | `knowledge/queries/*.json` and `*.sql` | +| 4 | Learnings | Error patterns and discovered fixes | `save_learning` tool (on-demand) | +| 5 | Runtime Context | Live schema inspection | `introspect_schema` tool (on-demand) | +| 6 | Institutional Knowledge | Docs, wikis, external references | [Custom tools](/sql-agent/guides/custom-tools/) (`agent.tools` config) | + +Layers 1–3 are loaded from the [Knowledge Base](/sql-agent/guides/knowledge-base/) and assembled into the system prompt automatically. Layer 4 is built up over time as the agent learns from errors. Layers 5 and 6 are available on-demand — the LLM calls them during the [tool loop](#agentic-tool-loop) when it needs live schema details or external context. ### Agentic Tool Loop diff --git a/docs/src/content/docs/guides/configuration.md b/docs/src/content/docs/guides/configuration.md index d24ecc0..bcff4ab 100644 --- a/docs/src/content/docs/guides/configuration.md +++ b/docs/src/content/docs/guides/configuration.md @@ -195,11 +195,17 @@ You can add custom indexes by providing an `index_mapping` array in the driver c 'database' => [ // ... 'index_mapping' => [ - 'custom_index' => \App\Models\CustomModel::class, + 'faq' => \App\Models\Faq::class, ], ], ``` +Custom indexes are fully integrated into the search system: + +- The `search_knowledge` tool automatically exposes custom indexes to the LLM as additional type options. +- The `ContextBuilder` searches custom indexes and includes matching results as "Additional Knowledge" in the system prompt. +- Both `database` and `pgvector` drivers support custom indexes identically. + Each model referenced in `index_mapping` must extend `Illuminate\Database\Eloquent\Model` and implement the `Knobik\SqlAgent\Contracts\Searchable` interface, which requires two methods: - `getSearchableColumns()` — Returns the column names to index for search. @@ -238,6 +244,22 @@ You can extend the agent with your own tools by listing class names in the `tool Each class must extend `Prism\Prism\Tool` and is resolved from the Laravel container with full dependency injection support. See the [Custom Tools](/sql-agent/guides/custom-tools/) guide for detailed examples and best practices. +### MCP Server Tools (Relay) + +If you have [Prism Relay](https://github.com/prism-php/relay) installed, you can bring tools from MCP servers into the agent by listing server names from `config/relay.php`: + +```php +'agent' => [ + // ... other options ... + 'relay' => [ + 'weather-server', + 'filesystem-server', + ], +], +``` + +The `relay` key is silently ignored when `prism-php/relay` is not installed. See the [Custom Tools](/sql-agent/guides/custom-tools/#mcp-server-tools-relay) guide for full setup instructions. + ## Learning SqlAgent can automatically learn from SQL errors and improve over time: @@ -265,21 +287,17 @@ Schedule::command('sql-agent:prune-learnings')->daily(); ## Knowledge -Configure where SqlAgent reads knowledge from at runtime: +Configure the knowledge base path: ```php 'knowledge' => [ 'path' => env('SQL_AGENT_KNOWLEDGE_PATH', resource_path('sql-agent/knowledge')), - 'source' => env('SQL_AGENT_KNOWLEDGE_SOURCE', 'database'), ], ``` -The `path` option sets the directory containing your JSON knowledge files. This path is used both when loading knowledge via `sql-agent:load-knowledge` and when the `files` source reads directly from disk. - -The `source` option controls how the agent loads knowledge at runtime: +The `path` option sets the directory containing your JSON knowledge files. This path is used by the `sql-agent:load-knowledge` command to import files into the database. -- **`database`** (default, recommended) — Reads from the `sql_agent_table_metadata`, `sql_agent_business_rules`, and `sql_agent_query_patterns` tables. You must run `php artisan sql-agent:load-knowledge` to import your JSON files first. Supports full-text search over knowledge. -- **`files`** — Reads directly from JSON files on disk. No import step needed, but full-text search is not available. +Knowledge is always read from the database at runtime — from the `sql_agent_table_metadata`, `sql_agent_business_rules`, and `sql_agent_query_patterns` tables. You must run `php artisan sql-agent:load-knowledge` after creating or changing knowledge files. ## Web Interface diff --git a/docs/src/content/docs/guides/custom-tools.md b/docs/src/content/docs/guides/custom-tools.md index 9b85968..4fb2523 100644 --- a/docs/src/content/docs/guides/custom-tools.md +++ b/docs/src/content/docs/guides/custom-tools.md @@ -135,6 +135,63 @@ SqlAgent validates custom tools at boot time: These errors surface immediately when the application boots, not at query time, so misconfigurations are caught early. +## MCP Server Tools (Relay) + +SqlAgent integrates with [Prism Relay](https://github.com/prism-php/relay) to bring tools from MCP (Model Context Protocol) servers into the agentic loop. This lets you connect external tool servers — filesystem access, API wrappers, code interpreters, or any MCP-compatible server — without writing custom PHP tool classes. + +### Installation + +Install the Relay package: + +```bash +composer require prism-php/relay +``` + +Then publish and configure your MCP servers in `config/relay.php` following the [Relay documentation](https://github.com/prism-php/relay). + +### Registering MCP Server Tools + +List the MCP server names (as defined in `config/relay.php`) in the `agent.relay` array in `config/sql-agent.php`: + +```php +'agent' => [ + // ... other options ... + 'tools' => [], + + 'relay' => [ + 'weather-server', + 'filesystem-server', + ], +], +``` + +At boot time, SqlAgent calls `Relay::tools($server)` for each configured server and registers all discovered tools alongside the built-in and custom tools. The LLM sees and can call all of them. + +:::tip +Relay tools are dynamically discovered `Tool` instances — you don't need to create PHP classes for them. Just configure the MCP server in `config/relay.php` and reference it by name in the `relay` array. +::: + +:::note +If `prism-php/relay` is not installed, the `relay` config key is silently ignored. This means you can ship a config that references Relay servers without requiring the package — useful for shared config across environments where only some have Relay installed. +::: + +### Combining Custom Tools and Relay + +Custom tools and Relay tools can be used together. They all end up in the same tool registry and are passed to the LLM equally: + +```php +'agent' => [ + 'tools' => [ + \App\SqlAgent\CurrentDateTimeTool::class, + ], + 'relay' => [ + 'weather-server', + ], +], +``` + +If a Relay tool has the same name as a built-in or custom tool, it will overwrite the previous registration (last write wins). + ## Tips - **Keep tools focused.** Each tool should do one thing well. Prefer two small tools over one tool with a `mode` parameter. diff --git a/docs/src/content/docs/guides/knowledge-base.md b/docs/src/content/docs/guides/knowledge-base.md index ec150a5..9d50b8b 100644 --- a/docs/src/content/docs/guides/knowledge-base.md +++ b/docs/src/content/docs/guides/knowledge-base.md @@ -214,5 +214,5 @@ php artisan sql-agent:load-knowledge --path=/custom/knowledge/path ``` :::caution -When using the default `database` knowledge source, you **must** run this command after creating or changing knowledge files. The agent reads from the database at runtime, not directly from disk. +You **must** run this command after creating or changing knowledge files. The agent always reads knowledge from the database at runtime, not directly from disk. ::: diff --git a/docs/src/content/docs/guides/multi-database.md b/docs/src/content/docs/guides/multi-database.md index 6b075e3..1f66406 100644 --- a/docs/src/content/docs/guides/multi-database.md +++ b/docs/src/content/docs/guides/multi-database.md @@ -100,7 +100,7 @@ Each connection can define its own table and column restrictions: Restrictions are enforced at every layer: - Schema introspection (listing tables, inspecting columns) -- Semantic model loading (table metadata from files or database) +- Semantic model loading (table metadata from database) - SQL execution (queries referencing denied tables are rejected) ## Web Interface @@ -125,7 +125,7 @@ Each step is visible in the streaming UI as a separate tool call with its connec ## Knowledge Loading -When using the `database` knowledge source (the default), table metadata is scoped per connection using the `connection` field in each JSON knowledge file. When you run `sql-agent:load-knowledge`, the loader reads the `connection` field from each table JSON file and stores it in the database alongside the metadata. +Table metadata is scoped per connection using the `connection` field in each JSON knowledge file. When you run `sql-agent:load-knowledge`, the loader reads the `connection` field from each table JSON file and stores it in the database alongside the metadata. ### Tagging Knowledge Files @@ -156,14 +156,6 @@ Files without a `connection` field default to `"default"` and are included for a Use the same logical names in your JSON files that you use as keys in the `database.connections` config. For example, if your config has `'crm' => [...]`, set `"connection": "crm"` in the corresponding knowledge files. ::: -### File-Based Knowledge Source - -When using the `files` knowledge source, you can also add a `connection` field to your JSON files. Tables with a matching connection (or no connection field) are included when building context for each database. Tables tagged with a different connection name are filtered out. - -:::caution -For the best multi-database experience, use the `database` knowledge source. It provides full-text search support and precise per-connection filtering via the `connection` column. -::: - ## Limitations - **No cross-database JOINs.** The agent runs separate queries and combines results programmatically. diff --git a/docs/src/content/docs/guides/recommended-configs.md b/docs/src/content/docs/guides/recommended-configs.md index b74afc4..7031073 100644 --- a/docs/src/content/docs/guides/recommended-configs.md +++ b/docs/src/content/docs/guides/recommended-configs.md @@ -34,9 +34,6 @@ SQL_AGENT_MAX_ITERATIONS=15 # Learning — enabled with auto error capture SQL_AGENT_LEARNING_ENABLED=true SQL_AGENT_AUTO_SAVE_ERRORS=true - -# Knowledge — database source for full-text search -SQL_AGENT_KNOWLEDGE_SOURCE=database ``` ### pgvector Database Connection @@ -92,9 +89,6 @@ SQL_AGENT_MAX_ITERATIONS=10 # Learning — enabled SQL_AGENT_LEARNING_ENABLED=true SQL_AGENT_AUTO_SAVE_ERRORS=true - -# Knowledge — database source -SQL_AGENT_KNOWLEDGE_SOURCE=database ``` No embeddings configuration is needed since the database search driver uses native full-text search instead of vector embeddings. diff --git a/docs/src/content/docs/reference/commands.md b/docs/src/content/docs/reference/commands.md index 0a0ca9d..a1cbf73 100644 --- a/docs/src/content/docs/reference/commands.md +++ b/docs/src/content/docs/reference/commands.md @@ -39,7 +39,7 @@ After running this command, generate embeddings for your existing knowledge base ## `sql-agent:load-knowledge` -Import knowledge files from disk into the database. Required when using the default `database` knowledge source. +Import knowledge files from disk into the database. Required after creating or changing knowledge files. ```bash php artisan sql-agent:load-knowledge diff --git a/src/Data/Context.php b/src/Data/Context.php index efb47ff..d4a9c01 100644 --- a/src/Data/Context.php +++ b/src/Data/Context.php @@ -16,14 +16,17 @@ public function __construct( public Collection $queryPatterns, /** @var Collection */ public Collection $learnings, - public ?string $runtimeSchema = null, - ) {} + /** @var Collection> */ + public ?Collection $customKnowledge = null, + ) { + $this->customKnowledge ??= collect(); + } public function toPromptString(): string { $sections = []; - // Layer 1: Semantic Model (Table Usage) + // Layer 1: Semantic Model if ($this->semanticModel) { $sections[] = $this->formatSection('DATABASE SCHEMA', $this->semanticModel); } @@ -41,7 +44,7 @@ public function toPromptString(): string $sections[] = $this->formatSection('SIMILAR QUERY EXAMPLES', $patterns); } - // Layer 5: Learnings + // Layer 4: Learnings if ($this->learnings->isNotEmpty()) { $learnings = $this->learnings ->map(fn ($l) => "- {$l['title']}: {$l['description']}") @@ -49,9 +52,21 @@ public function toPromptString(): string $sections[] = $this->formatSection('RELEVANT LEARNINGS', $learnings); } - // Layer 6: Runtime Schema - if ($this->runtimeSchema) { - $sections[] = $this->formatSection('RUNTIME SCHEMA INSPECTION', $this->runtimeSchema); + // Layer 5: Custom Knowledge + if ($this->customKnowledge->isNotEmpty()) { + $knowledge = $this->customKnowledge + ->map(function (array $item) { + $parts = []; + foreach ($item as $key => $value) { + if ($value !== null && $value !== '') { + $parts[] = "{$key}: {$value}"; + } + } + + return '- '.implode(' | ', $parts); + }) + ->implode("\n"); + $sections[] = $this->formatSection('ADDITIONAL KNOWLEDGE', $knowledge); } return implode("\n\n", $sections); @@ -72,11 +87,6 @@ public function hasLearnings(): bool return $this->learnings->isNotEmpty(); } - public function hasRuntimeSchema(): bool - { - return ! empty($this->runtimeSchema); - } - public function getQueryPatternCount(): int { return $this->queryPatterns->count(); @@ -93,6 +103,6 @@ public function isEmpty(): bool && empty($this->businessRules) && $this->queryPatterns->isEmpty() && $this->learnings->isEmpty() - && empty($this->runtimeSchema); + && $this->customKnowledge->isEmpty(); } } diff --git a/src/Search/SearchManager.php b/src/Search/SearchManager.php index 3ee3966..05e8aa4 100644 --- a/src/Search/SearchManager.php +++ b/src/Search/SearchManager.php @@ -114,4 +114,32 @@ public function delete(mixed $model): void { $this->driver()->delete($model); } + + /** + * Get all registered index names from the current driver. + * + * @return array + */ + public function getRegisteredIndexes(): array + { + $driver = $this->driver(); + + if (method_exists($driver, 'getIndexMapping')) { + return array_keys($driver->getIndexMapping()); + } + + return []; + } + + /** + * Get custom indexes (excluding built-in query_patterns and learnings). + * + * @return array + */ + public function getCustomIndexes(): array + { + $builtIn = ['query_patterns', 'learnings']; + + return array_values(array_diff($this->getRegisteredIndexes(), $builtIn)); + } } diff --git a/src/Services/BusinessRulesLoader.php b/src/Services/BusinessRulesLoader.php index 6748cf2..5cc690d 100644 --- a/src/Services/BusinessRulesLoader.php +++ b/src/Services/BusinessRulesLoader.php @@ -5,7 +5,6 @@ namespace Knobik\SqlAgent\Services; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\File; use Knobik\SqlAgent\Data\BusinessRuleData; use Knobik\SqlAgent\Enums\BusinessRuleType; use Knobik\SqlAgent\Models\BusinessRule; @@ -13,19 +12,13 @@ class BusinessRulesLoader { /** - * Load business rules from the configured source. + * Load business rules from the database. * * @return Collection */ public function load(): Collection { - $source = config('sql-agent.knowledge.source'); - - return match ($source) { - 'files' => $this->loadFromFiles(), - 'database' => $this->loadFromDatabase(), - default => throw new \InvalidArgumentException("Unknown knowledge source: {$source}"), - }; + return BusinessRule::all()->map(fn (BusinessRule $model) => $this->modelToBusinessRuleData($model)); } /** @@ -66,103 +59,6 @@ public function format(): string return implode("\n\n", $sections); } - /** - * Load business rules from JSON files. - * - * @return Collection - */ - protected function loadFromFiles(): Collection - { - $path = config('sql-agent.knowledge.path').'/business'; - - if (! File::isDirectory($path)) { - return collect(); - } - - $files = File::glob("{$path}/*.json"); - - return collect($files) - ->flatMap(fn (string $file) => $this->parseJsonFile($file)) - ->filter(); - } - - /** - * Load business rules from the database. - * - * @return Collection - */ - protected function loadFromDatabase(): Collection - { - return BusinessRule::all()->map(fn (BusinessRule $model) => $this->modelToBusinessRuleData($model)); - } - - /** - * Parse a JSON file into BusinessRuleData objects. - * - * @return Collection - */ - protected function parseJsonFile(string $filePath): Collection - { - try { - $data = json_decode(File::get($filePath), true, 512, JSON_THROW_ON_ERROR); - $rules = collect(); - - // Parse metrics - foreach ($data['metrics'] ?? [] as $metric) { - $rules->push(new BusinessRuleData( - name: $metric['name'], - description: $metric['definition'] ?? $metric['description'] ?? '', - type: BusinessRuleType::Metric, - calculation: $metric['calculation'] ?? null, - table: $metric['table'] ?? null, - )); - } - - // Parse business rules (can be array of strings or objects) - foreach ($data['business_rules'] ?? $data['rules'] ?? [] as $rule) { - if (is_string($rule)) { - $rules->push(new BusinessRuleData( - name: 'Business Rule', - description: $rule, - type: BusinessRuleType::Rule, - )); - } else { - $rules->push(new BusinessRuleData( - name: $rule['name'] ?? 'Business Rule', - description: $rule['description'] ?? $rule['rule'] ?? '', - type: BusinessRuleType::Rule, - tablesAffected: $rule['tables_affected'] ?? [], - )); - } - } - - // Parse gotchas - foreach ($data['common_gotchas'] ?? $data['gotchas'] ?? [] as $gotcha) { - if (is_string($gotcha)) { - $rules->push(new BusinessRuleData( - name: 'Gotcha', - description: $gotcha, - type: BusinessRuleType::Gotcha, - )); - } else { - $rules->push(new BusinessRuleData( - name: $gotcha['issue'] ?? $gotcha['name'] ?? 'Gotcha', - description: $gotcha['description'] ?? $gotcha['issue'] ?? '', - type: BusinessRuleType::Gotcha, - tablesAffected: $gotcha['tables_affected'] ?? [], - solution: $gotcha['solution'] ?? null, - )); - } - } - - return $rules; - } catch (\JsonException $e) { - report($e); - - return collect(); - } - } - /** * Convert a BusinessRule model to a BusinessRuleData DTO. */ diff --git a/src/Services/ContextBuilder.php b/src/Services/ContextBuilder.php index 940ce06..4d35164 100644 --- a/src/Services/ContextBuilder.php +++ b/src/Services/ContextBuilder.php @@ -6,16 +6,16 @@ use Illuminate\Support\Collection; use Knobik\SqlAgent\Data\Context; -use Knobik\SqlAgent\Models\Learning; -use Knobik\SqlAgent\Support\TextAnalyzer; +use Knobik\SqlAgent\Data\QueryPatternData; +use Knobik\SqlAgent\Search\SearchManager; +use Knobik\SqlAgent\Search\SearchResult; class ContextBuilder { public function __construct( protected SemanticModelLoader $semanticLoader, protected BusinessRulesLoader $rulesLoader, - protected QueryPatternSearch $patternSearch, - protected SchemaIntrospector $introspector, + protected SearchManager $searchManager, protected ConnectionRegistry $connectionRegistry, ) {} @@ -24,29 +24,12 @@ public function __construct( */ public function build(string $question): Context { - $semanticSections = []; - $schemaSections = []; - - foreach ($this->connectionRegistry->all() as $name => $config) { - $laravelConnection = $config->connection; - - $semantic = $this->semanticLoader->format($laravelConnection, $name); - if ($semantic && $semantic !== 'No table metadata available.') { - $semanticSections[] = "## Connection: {$name} ({$config->label})\n{$config->description}\n\n{$semantic}"; - } - - $schema = $this->introspector->getRelevantSchema($question, $laravelConnection, $name); - if ($schema) { - $schemaSections[] = "## Connection: {$name} ({$config->label})\n\n{$schema}"; - } - } - return new Context( - semanticModel: implode("\n\n---\n\n", $semanticSections) ?: 'No table metadata available.', + semanticModel: $this->buildSemanticModel(), businessRules: $this->rulesLoader->format(), - queryPatterns: $this->patternSearch->search($question), + queryPatterns: $this->searchQueryPatterns($question), learnings: $this->searchLearnings($question), - runtimeSchema: implode("\n\n---\n\n", $schemaSections) ?: null, + customKnowledge: $this->searchCustomIndexes($question), ); } @@ -59,16 +42,15 @@ public function buildWithOptions( bool $includeBusinessRules = true, bool $includeQueryPatterns = true, bool $includeLearnings = true, - bool $includeRuntimeSchema = true, int $queryPatternLimit = 3, int $learningLimit = 5, ): Context { return new Context( semanticModel: $includeSemanticModel ? $this->buildSemanticModel() : '', businessRules: $includeBusinessRules ? $this->rulesLoader->format() : '', - queryPatterns: $includeQueryPatterns ? $this->patternSearch->search($question, $queryPatternLimit) : collect(), + queryPatterns: $includeQueryPatterns ? $this->searchQueryPatterns($question, $queryPatternLimit) : collect(), learnings: $includeLearnings ? $this->searchLearnings($question, $learningLimit) : collect(), - runtimeSchema: $includeRuntimeSchema ? $this->buildRuntimeSchema($question) : null, + customKnowledge: $includeQueryPatterns ? $this->searchCustomIndexes($question) : collect(), ); } @@ -82,115 +64,84 @@ public function buildMinimal(): Context businessRules: $this->rulesLoader->format(), queryPatterns: collect(), learnings: collect(), - runtimeSchema: null, ); } /** - * Build context with runtime introspection only. + * Search for query patterns via SearchManager. + * + * @return Collection */ - public function buildRuntimeOnly(string $question): Context + protected function searchQueryPatterns(string $question, int $limit = 3): Collection { - return new Context( - semanticModel: '', - businessRules: '', - queryPatterns: collect(), - learnings: collect(), - runtimeSchema: $this->buildRuntimeSchema($question), - ); + return $this->searchManager->search($question, 'query_patterns', $limit) + ->map(fn (SearchResult $result) => new QueryPatternData( + name: $result->model->getAttribute('name'), + question: $result->model->getAttribute('question'), + sql: $result->model->getAttribute('sql'), + summary: $result->model->getAttribute('summary'), + tablesUsed: $result->model->getAttribute('tables_used') ?? [], + dataQualityNotes: $result->model->getAttribute('data_quality_notes'), + )); } /** - * Build semantic model across all configured connections. + * Search for relevant learnings via SearchManager. + * + * @return Collection> */ - protected function buildSemanticModel(): string + protected function searchLearnings(string $question, int $limit = 5): Collection { - $sections = []; - - foreach ($this->connectionRegistry->all() as $name => $config) { - $semantic = $this->semanticLoader->format($config->connection, $name); - if ($semantic && $semantic !== 'No table metadata available.') { - $sections[] = "## Connection: {$name} ({$config->label})\n{$config->description}\n\n{$semantic}"; - } + if (! config('sql-agent.learning.enabled')) { + return collect(); } - return implode("\n\n---\n\n", $sections) ?: 'No table metadata available.'; + return $this->searchManager->search($question, 'learnings', $limit) // @phpstan-ignore return.type + ->map(fn (SearchResult $result) => [ + 'title' => $result->model->getAttribute('title'), + 'description' => $result->model->getAttribute('description'), + 'category' => $result->model->getAttribute('category')?->value, + 'sql' => $result->model->getAttribute('sql'), + ]); } /** - * Build runtime schema across all configured connections. + * Search custom indexes for additional knowledge. + * + * @return Collection> */ - protected function buildRuntimeSchema(string $question): ?string + protected function searchCustomIndexes(string $question, int $limit = 5): Collection { - $sections = []; + $customIndexes = $this->searchManager->getCustomIndexes(); - foreach ($this->connectionRegistry->all() as $name => $config) { - $schema = $this->introspector->getRelevantSchema($question, $config->connection, $name); - if ($schema) { - $sections[] = "## Connection: {$name} ({$config->label})\n\n{$schema}"; - } + if (empty($customIndexes)) { + return collect(); } - return implode("\n\n---\n\n", $sections) ?: null; + return $this->searchManager->searchMultiple($question, $customIndexes, $limit) + ->map(function (SearchResult $result) { + /** @var \Illuminate\Database\Eloquent\Model&\Knobik\SqlAgent\Contracts\Searchable $model */ + $model = $result->model; + + return $model->toSearchableArray(); + }); } /** - * Search for relevant learnings. - * - * @return Collection> + * Build semantic model across all configured connections. */ - protected function searchLearnings(string $question, int $limit = 5): Collection + protected function buildSemanticModel(): string { - if (! config('sql-agent.learning.enabled')) { - return collect(); - } - - $keywords = TextAnalyzer::extractKeywords($question); - - if (empty($keywords)) { - return Learning::query() // @phpstan-ignore return.type - ->latest() - ->limit($limit) - ->get() - ->map(fn (Learning $l) => [ - 'title' => $l->title, - 'description' => $l->description, - 'category' => $l->category?->value, - 'sql' => $l->sql, - ]); - } - - $query = Learning::query(); - - foreach ($keywords as $keyword) { - $term = '%'.strtolower($keyword).'%'; - $query->where(function ($q) use ($term) { - $q->whereRaw('LOWER(title) LIKE ?', [$term]) - ->orWhereRaw('LOWER(description) LIKE ?', [$term]); - }); - } + $sections = []; - $results = $query->limit($limit)->get(); - - // If strict keyword match returned nothing, try looser search - if ($results->isEmpty()) { - $query = Learning::query(); - $query->where(function ($q) use ($keywords) { - foreach ($keywords as $keyword) { - $term = '%'.strtolower($keyword).'%'; - $q->orWhereRaw('LOWER(title) LIKE ?', [$term]) - ->orWhereRaw('LOWER(description) LIKE ?', [$term]); - } - }); - $results = $query->limit($limit)->get(); + foreach ($this->connectionRegistry->all() as $name => $config) { + $semantic = $this->semanticLoader->format($config->connection, $name); + if ($semantic && $semantic !== 'No table metadata available.') { + $sections[] = "## Connection: {$name} ({$config->label})\n{$config->description}\n\n{$semantic}"; + } } - return $results->map(fn (Learning $l) => [ // @phpstan-ignore return.type - 'title' => $l->title, - 'description' => $l->description, - 'category' => $l->category?->value, - 'sql' => $l->sql, - ]); + return implode("\n\n---\n\n", $sections) ?: 'No table metadata available.'; } /** @@ -208,20 +159,4 @@ public function getRulesLoader(): BusinessRulesLoader { return $this->rulesLoader; } - - /** - * Get the query pattern search service. - */ - public function getPatternSearch(): QueryPatternSearch - { - return $this->patternSearch; - } - - /** - * Get the schema introspector. - */ - public function getIntrospector(): SchemaIntrospector - { - return $this->introspector; - } } diff --git a/src/Services/QueryPatternSearch.php b/src/Services/QueryPatternSearch.php deleted file mode 100644 index 3911677..0000000 --- a/src/Services/QueryPatternSearch.php +++ /dev/null @@ -1,316 +0,0 @@ - - */ - public function search(string $question, ?int $limit = null): Collection - { - $limit = $limit ?? $this->defaultLimit; - $source = config('sql-agent.knowledge.source'); - - return match ($source) { - 'files' => $this->searchFiles($question, $limit), - 'database' => $this->searchDatabase($question, $limit), - default => throw new \InvalidArgumentException("Unknown knowledge source: {$source}"), - }; - } - - /** - * Search query patterns from files. - * - * @return Collection - */ - protected function searchFiles(string $question, int $limit): Collection - { - $patterns = $this->loadAllFromFiles(); - - if ($patterns->isEmpty()) { - return collect(); - } - - // Simple keyword-based similarity search - $questionWords = TextAnalyzer::extractKeywords($question); - - return $patterns - ->map(fn (QueryPatternData $pattern) => [ - 'pattern' => $pattern, - 'score' => $this->calculateSimilarity($questionWords, $pattern), - ]) - ->sortByDesc('score') - ->take($limit) - ->filter(fn (array $item) => $item['score'] > 0) - ->pluck('pattern'); - } - - /** - * Search query patterns from the database. - * - * @return Collection - */ - protected function searchDatabase(string $question, int $limit): Collection - { - $driver = config('database.connections.'.config('sql-agent.database.storage_connection').'.driver'); - - // Use full-text search for MySQL - if ($driver === 'mysql') { - return $this->searchWithFullText($question, $limit); - } - - // Fall back to LIKE-based search - return $this->searchWithLike($question, $limit); - } - - /** - * Search using MySQL full-text index. - * - * @return Collection - */ - protected function searchWithFullText(string $question, int $limit): Collection - { - $searchTerm = TextAnalyzer::prepareSearchTerm($question); - - if (empty($searchTerm)) { - return QueryPattern::query() - ->limit($limit) - ->get() - ->map(fn (QueryPattern $model) => $this->modelToQueryPatternData($model)); - } - - return QueryPattern::query() - ->selectRaw('*, MATCH(name, question, summary) AGAINST(? IN NATURAL LANGUAGE MODE) as relevance', [$searchTerm]) - ->whereRaw('MATCH(name, question, summary) AGAINST(? IN NATURAL LANGUAGE MODE)', [$searchTerm]) - ->orderByDesc('relevance') - ->limit($limit) - ->get() - ->map(fn (QueryPattern $model) => $this->modelToQueryPatternData($model)); - } - - /** - * Search using LIKE queries. - * - * @return Collection - */ - protected function searchWithLike(string $question, int $limit): Collection - { - $keywords = TextAnalyzer::extractKeywords($question); - - if (empty($keywords)) { - return QueryPattern::query() - ->limit($limit) - ->get() - ->map(fn (QueryPattern $model) => $this->modelToQueryPatternData($model)); - } - - $query = QueryPattern::query(); - - foreach ($keywords as $keyword) { - $term = '%'.strtolower($keyword).'%'; - $query->where(function ($q) use ($term) { - $q->whereRaw('LOWER(name) LIKE ?', [$term]) - ->orWhereRaw('LOWER(question) LIKE ?', [$term]) - ->orWhereRaw('LOWER(summary) LIKE ?', [$term]); - }); - } - - return $query - ->limit($limit) - ->get() - ->map(fn (QueryPattern $model) => $this->modelToQueryPatternData($model)); - } - - /** - * Load all query patterns from files. - * - * @return Collection - */ - protected function loadAllFromFiles(): Collection - { - $path = config('sql-agent.knowledge.path').'/queries'; - - if (! File::isDirectory($path)) { - return collect(); - } - - $sqlFiles = File::glob("{$path}/*.sql"); - $jsonFiles = File::glob("{$path}/*.json"); - - $patterns = collect(); - - foreach ($sqlFiles as $file) { - $patterns = $patterns->merge($this->parseSqlFile($file)); - } - - foreach ($jsonFiles as $file) { - $patterns = $patterns->merge($this->parseJsonFile($file)); - } - - return $patterns; - } - - /** - * Parse a .sql file containing query patterns. - * - * @return Collection - */ - protected function parseSqlFile(string $filePath): Collection - { - $content = File::get($filePath); - $patterns = collect(); - - // Pattern format: - // -- name - // -- - // -- description text - // -- - // -- - // SELECT ... - // -- - - $regex = '/--\s*([^<]+)<\/query\s+name>\s*(?:--\s*\s*([\s\S]*?)--\s*<\/query\s+description>\s*)?--\s*\s*([\s\S]*?)--\s*<\/query>/i'; - - if (preg_match_all($regex, $content, $matches, PREG_SET_ORDER)) { - foreach ($matches as $match) { - $name = trim($match[1]); - $description = trim(preg_replace('/^--\s*/m', '', $match[2])); - $sql = trim($match[3]); - - // Extract tables from SQL - $tablesUsed = TextAnalyzer::extractTablesFromSql($sql); - - $patterns->push(new QueryPatternData( - name: $name, - question: $description, - sql: $sql, - summary: $description, - tablesUsed: $tablesUsed, - )); - } - } - - return $patterns; - } - - /** - * Parse a JSON file containing query patterns. - * - * @return Collection - */ - protected function parseJsonFile(string $filePath): Collection - { - try { - $data = json_decode(File::get($filePath), true, 512, JSON_THROW_ON_ERROR); - $patterns = collect(); - - foreach ($data['patterns'] ?? $data['queries'] ?? [$data] as $pattern) { - if (! isset($pattern['name']) && ! isset($pattern['question'])) { - continue; - } - - $patterns->push(new QueryPatternData( - name: $pattern['name'] ?? $pattern['question'] ?? 'Query', - question: $pattern['question'] ?? $pattern['name'] ?? '', - sql: $pattern['sql'] ?? $pattern['query'] ?? '', - summary: $pattern['summary'] ?? $pattern['description'] ?? null, - tablesUsed: $pattern['tables_used'] ?? $pattern['tables'] ?? [], - dataQualityNotes: $pattern['data_quality_notes'] ?? $pattern['notes'] ?? null, - )); - } - - return $patterns; - } catch (\JsonException $e) { - report($e); - - return collect(); - } - } - - /** - * Convert a QueryPattern model to a QueryPatternData DTO. - */ - protected function modelToQueryPatternData(QueryPattern $model): QueryPatternData - { - return new QueryPatternData( - name: $model->name, - question: $model->question, - sql: $model->sql, - summary: $model->summary, - tablesUsed: $model->tables_used ?? [], - dataQualityNotes: $model->data_quality_notes, - ); - } - - /** - * Calculate similarity score between keywords and a pattern. - */ - protected function calculateSimilarity(array $keywords, QueryPatternData $pattern): float - { - if (empty($keywords)) { - return 0; - } - - $patternText = strtolower($pattern->name.' '.$pattern->question.' '.($pattern->summary ?? '')); - $patternWords = TextAnalyzer::extractKeywords($patternText); - - if (empty($patternWords)) { - return 0; - } - - $matches = 0; - foreach ($keywords as $keyword) { - foreach ($patternWords as $patternWord) { - // Exact match - if ($keyword === $patternWord) { - $matches += 2; - } - // Partial match (contains) - elseif (str_contains($patternWord, $keyword) || str_contains($keyword, $patternWord)) { - $matches += 1; - } - } - } - - return $matches / max(count($keywords), count($patternWords)); - } - - /** - * Get all query patterns without search. - * - * @return Collection - */ - public function all(): Collection - { - $source = config('sql-agent.knowledge.source'); - - return match ($source) { - 'files' => $this->loadAllFromFiles(), - 'database' => QueryPattern::all()->map(fn (QueryPattern $model) => $this->modelToQueryPatternData($model)), - default => collect(), - }; - } - - /** - * Set the default limit for search results. - */ - public function setDefaultLimit(int $limit): self - { - $this->defaultLimit = $limit; - - return $this; - } -} diff --git a/src/Services/SemanticModelLoader.php b/src/Services/SemanticModelLoader.php index 14f1255..5fbda43 100644 --- a/src/Services/SemanticModelLoader.php +++ b/src/Services/SemanticModelLoader.php @@ -5,7 +5,6 @@ namespace Knobik\SqlAgent\Services; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\File; use Knobik\SqlAgent\Data\TableSchema; use Knobik\SqlAgent\Models\TableMetadata; @@ -16,19 +15,19 @@ public function __construct( ) {} /** - * Load table metadata from the configured source. + * Load table metadata from the database. * * @return Collection */ public function load(?string $connection = null, ?string $connectionName = null): Collection { - $source = config('sql-agent.knowledge.source'); + $query = TableMetadata::query(); + + if ($connection !== null) { + $query->forConnection($connection); + } - $tables = match ($source) { - 'files' => $this->loadFromFiles($connection), - 'database' => $this->loadFromDatabase($connection), - default => throw new \InvalidArgumentException("Unknown knowledge source: {$source}"), - }; + $tables = $query->get()->map(fn (TableMetadata $model) => $this->modelToTableSchema($model)); return $this->applyAccessControl($tables, $connectionName); } @@ -49,79 +48,6 @@ public function format(?string $connection = null, ?string $connectionName = nul ->implode("\n\n---\n\n"); } - /** - * Load table metadata from JSON files. - * - * @return Collection - */ - protected function loadFromFiles(?string $connection = null): Collection - { - $path = config('sql-agent.knowledge.path').'/tables'; - - if (! File::isDirectory($path)) { - return collect(); - } - - $files = File::glob("{$path}/*.json"); - - return collect($files) - ->map(fn (string $file) => $this->parseJsonFile($file)) - ->filter() - ->filter(fn (TableSchema $table, $key) => $this->matchesConnection($table, $connection)) - ->values(); - } - - /** - * Load table metadata from the database. - * - * @return Collection - */ - protected function loadFromDatabase(?string $connection = null): Collection - { - $query = TableMetadata::query(); - - if ($connection !== null) { - $query->forConnection($connection); - } - - return $query->get()->map(fn (TableMetadata $model) => $this->modelToTableSchema($model)); - } - - /** - * Parse a JSON file into a TableSchema. - */ - protected function parseJsonFile(string $filePath): ?TableSchema - { - try { - $data = json_decode(File::get($filePath), true, 512, JSON_THROW_ON_ERROR); - - return $this->arrayToTableSchema($data); - } catch (\JsonException $e) { - report($e); - - return null; - } - } - - /** - * Convert an array to a TableSchema DTO. - */ - protected function arrayToTableSchema(array $data): TableSchema - { - $columns = $data['columns'] ?? $data['table_columns'] ?? []; - $relationships = $data['relationships'] ?? []; - - return new TableSchema( - tableName: $data['table'] ?? $data['table_name'] ?? '', - description: $data['description'] ?? $data['table_description'] ?? null, - columns: $columns, - relationships: $relationships, - dataQualityNotes: $data['data_quality_notes'] ?? [], - useCases: $data['use_cases'] ?? [], - connection: $data['connection'] ?? null, - ); - } - /** * Convert a TableMetadata model to a TableSchema DTO. */ @@ -136,24 +62,6 @@ protected function modelToTableSchema(TableMetadata $model): TableSchema ); } - /** - * Check if a table belongs to the specified connection. - * - * When a connection is specified, only tables with a matching `connection` - * field in their JSON file are included. Tables without a `connection` - * field default to "default" and are included for all connections. - */ - protected function matchesConnection(TableSchema $table, ?string $connection): bool - { - if ($connection === null) { - return true; - } - - $tableConnection = $table->connection ?? 'default'; - - return $tableConnection === $connection || $tableConnection === 'default'; - } - /** * Filter tables and columns through access control. * diff --git a/src/SqlAgentServiceProvider.php b/src/SqlAgentServiceProvider.php index 457f4d4..c0ec34c 100644 --- a/src/SqlAgentServiceProvider.php +++ b/src/SqlAgentServiceProvider.php @@ -75,6 +75,14 @@ public function register(): void $registry->register($tool); } + // Register tools from Relay MCP servers (if prism-php/relay is installed) + if (class_exists(\Prism\Relay\Facades\Relay::class)) { + foreach (config('sql-agent.agent.relay') as $server) { + $relayTools = \Prism\Relay\Facades\Relay::tools($server); + $registry->registerMany($relayTools); + } + } + return $registry; }); diff --git a/src/Tools/SearchKnowledgeTool.php b/src/Tools/SearchKnowledgeTool.php index bdb66a3..5824373 100644 --- a/src/Tools/SearchKnowledgeTool.php +++ b/src/Tools/SearchKnowledgeTool.php @@ -4,6 +4,7 @@ namespace Knobik\SqlAgent\Tools; +use Knobik\SqlAgent\Contracts\Searchable; use Knobik\SqlAgent\Search\SearchManager; use Knobik\SqlAgent\Search\SearchResult; use Prism\Prism\Tool; @@ -14,11 +15,14 @@ class SearchKnowledgeTool extends Tool public function __construct( protected SearchManager $searchManager, ) { + $indexes = $this->searchManager->getRegisteredIndexes(); + $enumValues = ['all', ...$indexes]; + $this ->as('search_knowledge') ->for('Search the knowledge base for relevant query patterns and learnings. Use this to find similar queries, understand business logic, or discover past learnings about the database.') ->withStringParameter('query', 'The search query to find relevant knowledge.') - ->withEnumParameter('type', "Filter results: 'all' (default), 'patterns' (saved query patterns), or 'learnings' (discovered fixes/gotchas).", ['all', 'patterns', 'learnings'], required: false) + ->withEnumParameter('type', "Filter results by index: 'all' (default) searches everything, or specify a specific index name.", $enumValues, required: false) ->withNumberParameter('limit', 'Maximum number of results to return.', required: false) ->using($this); } @@ -31,31 +35,62 @@ public function __invoke(string $query, string $type = 'all', int $limit = 5): s throw new RuntimeException('Search query cannot be empty.'); } - if (! in_array($type, ['all', 'patterns', 'learnings'])) { + $registeredIndexes = $this->searchManager->getRegisteredIndexes(); + + if ($type !== 'all' && ! in_array($type, $registeredIndexes)) { $type = 'all'; } $limit = min($limit, 20); $results = []; - if (in_array($type, ['all', 'patterns'])) { - $results['query_patterns'] = $this->searchPatterns($query, $limit); + if ($type === 'all') { + foreach ($registeredIndexes as $index) { + $results[$index] = $this->searchIndex($query, $index, $limit); + } + } else { + $results[$type] = $this->searchIndex($query, $type, $limit); } - if (in_array($type, ['all', 'learnings'])) { - $results['learnings'] = $this->searchLearnings($query, $limit); + $total = 0; + foreach ($results as $indexResults) { + $total += count($indexResults); } - - $results['total_found'] = count($results['query_patterns'] ?? []) + count($results['learnings'] ?? []); + $results['total_found'] = $total; return json_encode($results, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } - protected function searchPatterns(string $query, int $limit): array + /** + * Search a single index and format results. + * + * @return array> + */ + protected function searchIndex(string $query, string $index, int $limit): array { - $searchResults = $this->searchManager->search($query, 'query_patterns', $limit); + // Guard: skip learnings when learning is disabled + if ($index === 'learnings' && ! config('sql-agent.learning.enabled')) { + return []; + } + + $searchResults = $this->searchManager->search($query, $index, $limit); + + return match ($index) { + 'query_patterns' => $this->formatQueryPatterns($searchResults), + 'learnings' => $this->formatLearnings($searchResults), + default => $this->formatCustomIndex($searchResults), + }; + } - return $searchResults->map(fn (SearchResult $result) => [ + /** + * Format query pattern search results. + * + * @param \Illuminate\Support\Collection $results + * @return array> + */ + protected function formatQueryPatterns($results): array + { + return $results->map(fn (SearchResult $result) => [ 'name' => $result->model->getAttribute('name'), 'question' => $result->model->getAttribute('question'), 'sql' => $result->model->getAttribute('sql'), @@ -65,15 +100,15 @@ protected function searchPatterns(string $query, int $limit): array ])->toArray(); } - protected function searchLearnings(string $query, int $limit): array + /** + * Format learning search results. + * + * @param \Illuminate\Support\Collection $results + * @return array> + */ + protected function formatLearnings($results): array { - if (! config('sql-agent.learning.enabled')) { - return []; - } - - $searchResults = $this->searchManager->search($query, 'learnings', $limit); - - return $searchResults->map(fn (SearchResult $result) => [ + return $results->map(fn (SearchResult $result) => [ 'title' => $result->model->getAttribute('title'), 'description' => $result->model->getAttribute('description'), 'category' => $result->model->getAttribute('category')?->value, @@ -81,4 +116,22 @@ protected function searchLearnings(string $query, int $limit): array 'relevance_score' => $result->score, ])->toArray(); } + + /** + * Format custom index search results using toSearchableArray(). + * + * @param \Illuminate\Support\Collection $results + * @return array> + */ + protected function formatCustomIndex($results): array + { + return $results->map(function (SearchResult $result) { + $data = $result->model instanceof Searchable + ? $result->model->toSearchableArray() + : []; + $data['relevance_score'] = $result->score; + + return $data; + })->toArray(); + } } diff --git a/tests/Fixtures/FakeRelayFacade.php b/tests/Fixtures/FakeRelayFacade.php new file mode 100644 index 0000000..9759171 --- /dev/null +++ b/tests/Fixtures/FakeRelayFacade.php @@ -0,0 +1,16 @@ +toBe('/custom/path'); }); - - it('knowledge source config supports files and database', function () { - config(['sql-agent.knowledge.source' => 'files']); - expect(config('sql-agent.knowledge.source'))->toBe('files'); - - config(['sql-agent.knowledge.source' => 'database']); - expect(config('sql-agent.knowledge.source'))->toBe('database'); - }); }); describe('UI Configuration', function () { diff --git a/tests/Unit/DataTest.php b/tests/Unit/DataTest.php index a3080aa..fec7a28 100644 --- a/tests/Unit/DataTest.php +++ b/tests/Unit/DataTest.php @@ -216,7 +216,7 @@ businessRules: 'Rules info', queryPatterns: collect(), learnings: collect(), - runtimeSchema: null, + ); expect($context->semanticModel)->toBe('Table info'); @@ -235,7 +235,6 @@ businessRules: 'Rules here', queryPatterns: collect([$pattern]), learnings: collect([['title' => 'Learning 1', 'description' => 'Desc 1']]), - runtimeSchema: 'Runtime schema', ); $prompt = $context->toPromptString(); @@ -245,7 +244,6 @@ expect($prompt)->toContain('# BUSINESS RULES'); expect($prompt)->toContain('# SIMILAR QUERY EXAMPLES'); expect($prompt)->toContain('# RELEVANT LEARNINGS'); - expect($prompt)->toContain('# RUNTIME SCHEMA INSPECTION'); }); it('detects empty context', function () { @@ -254,7 +252,7 @@ businessRules: '', queryPatterns: collect(), learnings: collect(), - runtimeSchema: null, + ); expect($emptyContext->isEmpty())->toBeTrue(); @@ -264,7 +262,7 @@ businessRules: '', queryPatterns: collect(), learnings: collect(), - runtimeSchema: null, + ); expect($nonEmptyContext->isEmpty())->toBeFalse(); @@ -281,7 +279,7 @@ learnings: collect([ ['title' => 'L1', 'description' => 'D1'], ]), - runtimeSchema: null, + ); expect($context->getQueryPatternCount())->toBe(2); diff --git a/tests/Unit/RelayToolRegistrationTest.php b/tests/Unit/RelayToolRegistrationTest.php new file mode 100644 index 0000000..416c7b3 --- /dev/null +++ b/tests/Unit/RelayToolRegistrationTest.php @@ -0,0 +1,117 @@ +forgetInstance(ToolRegistry::class); +}); + +describe('Relay MCP Tool Registration', function () { + it('registers relay tools when configured', function () { + $fakeTool = (new Tool) + ->as('mcp_weather') + ->for('Get weather from MCP server') + ->using(fn () => 'sunny'); + + $mock = Mockery::mock(); + $mock->shouldReceive('tools') + ->with('weather-server') + ->once() + ->andReturn([$fakeTool]); + Relay::swap($mock); + + config()->set('sql-agent.agent.relay', ['weather-server']); + + $registry = app(ToolRegistry::class); + + expect($registry->has('mcp_weather'))->toBeTrue(); + expect($registry->get('mcp_weather'))->toBeInstanceOf(Tool::class); + }); + + it('relay tools appear alongside built-in and custom tools', function () { + $relayTool = (new Tool) + ->as('mcp_calculator') + ->for('Calculator from MCP server') + ->using(fn () => '42'); + + $mock = Mockery::mock(); + $mock->shouldReceive('tools') + ->with('calc-server') + ->once() + ->andReturn([$relayTool]); + Relay::swap($mock); + + config()->set('sql-agent.agent.tools', [FakeCustomTool::class]); + config()->set('sql-agent.agent.relay', ['calc-server']); + + $registry = app(ToolRegistry::class); + + // Built-in (5) + custom (1) + relay (1) = 7 + expect($registry->count())->toBe(7); + expect($registry->has('run_sql'))->toBeTrue(); + expect($registry->has('fake_custom'))->toBeTrue(); + expect($registry->has('mcp_calculator'))->toBeTrue(); + }); + + it('registers tools from multiple relay servers', function () { + $weatherTool = (new Tool) + ->as('mcp_weather') + ->for('Weather tool') + ->using(fn () => 'sunny'); + + $calcTool = (new Tool) + ->as('mcp_calculator') + ->for('Calculator tool') + ->using(fn () => '42'); + + $mock = Mockery::mock(); + $mock->shouldReceive('tools') + ->with('weather-server') + ->once() + ->andReturn([$weatherTool]); + $mock->shouldReceive('tools') + ->with('calc-server') + ->once() + ->andReturn([$calcTool]); + Relay::swap($mock); + + config()->set('sql-agent.agent.relay', ['weather-server', 'calc-server']); + + $registry = app(ToolRegistry::class); + + expect($registry->has('mcp_weather'))->toBeTrue(); + expect($registry->has('mcp_calculator'))->toBeTrue(); + }); + + it('skipped when relay config is empty', function () { + $mock = Mockery::mock(); + $mock->shouldReceive('tools')->never(); + Relay::swap($mock); + + config()->set('sql-agent.agent.relay', []); + + $registry = app(ToolRegistry::class); + + // Only built-in tools + expect($registry->count())->toBe(5); + }); +}); + +// Fixture reused from CustomToolRegistrationTest — defined if not already loaded +if (! class_exists(FakeCustomTool::class)) { + class FakeCustomTool extends Tool + { + public function __construct() + { + $this + ->as('fake_custom') + ->for('A fake custom tool for testing') + ->using(fn () => 'fake'); + } + } +} diff --git a/tests/Unit/Search/SearchManagerTest.php b/tests/Unit/Search/SearchManagerTest.php index 20144b0..8bbecec 100644 --- a/tests/Unit/Search/SearchManagerTest.php +++ b/tests/Unit/Search/SearchManagerTest.php @@ -93,3 +93,49 @@ expect($driver)->toBeInstanceOf(PgvectorSearchDriver::class); }); + +test('getRegisteredIndexes returns default indexes for database driver', function () { + $indexes = $this->manager->getRegisteredIndexes(); + + expect($indexes)->toContain('query_patterns'); + expect($indexes)->toContain('learnings'); +}); + +test('getRegisteredIndexes returns empty array for null driver', function () { + config(['sql-agent.search.default' => 'null']); + + $manager = new SearchManager(app()); + $indexes = $manager->getRegisteredIndexes(); + + expect($indexes)->toBe([]); +}); + +test('getCustomIndexes returns empty when no custom indexes configured', function () { + $customIndexes = $this->manager->getCustomIndexes(); + + expect($customIndexes)->toBe([]); +}); + +test('getCustomIndexes excludes built-in indexes', function () { + config(['sql-agent.search.drivers.database.index_mapping' => [ + 'my_custom_index' => \Knobik\SqlAgent\Models\QueryPattern::class, + ]]); + + $manager = new SearchManager(app()); + $customIndexes = $manager->getCustomIndexes(); + + expect($customIndexes)->toBe(['my_custom_index']); +}); + +test('getRegisteredIndexes includes custom indexes', function () { + config(['sql-agent.search.drivers.database.index_mapping' => [ + 'my_custom_index' => \Knobik\SqlAgent\Models\QueryPattern::class, + ]]); + + $manager = new SearchManager(app()); + $indexes = $manager->getRegisteredIndexes(); + + expect($indexes)->toContain('query_patterns'); + expect($indexes)->toContain('learnings'); + expect($indexes)->toContain('my_custom_index'); +}); diff --git a/tests/Unit/Services/SemanticModelLoaderTest.php b/tests/Unit/Services/SemanticModelLoaderTest.php index 9600e70..ef75ee0 100644 --- a/tests/Unit/Services/SemanticModelLoaderTest.php +++ b/tests/Unit/Services/SemanticModelLoaderTest.php @@ -1,7 +1,7 @@ artisan('migrate'); - config()->set('sql-agent.knowledge.source', 'files'); - - // Create a temporary knowledge directory with table files - $this->knowledgePath = sys_get_temp_dir().'/sql-agent-test-knowledge-'.uniqid(); - $tablesPath = $this->knowledgePath.'/tables'; - File::makeDirectory($tablesPath, 0755, true); - - File::put("{$tablesPath}/users.json", json_encode([ - 'table' => 'users', + // Create test table metadata in the database + TableMetadata::create([ + 'connection' => 'default', + 'table_name' => 'users', 'description' => 'User accounts', 'columns' => [ 'id' => 'integer, Primary key', @@ -27,10 +22,11 @@ 'password' => 'string, hashed', ], 'relationships' => [], - ])); + ]); - File::put("{$tablesPath}/orders.json", json_encode([ - 'table' => 'orders', + TableMetadata::create([ + 'connection' => 'default', + 'table_name' => 'orders', 'description' => 'Customer orders', 'columns' => [ 'id' => 'integer, Primary key', @@ -38,23 +34,18 @@ 'total' => 'decimal', ], 'relationships' => ['belongsTo users via user_id → users.id'], - ])); + ]); - File::put("{$tablesPath}/secrets.json", json_encode([ - 'table' => 'secrets', + TableMetadata::create([ + 'connection' => 'default', + 'table_name' => 'secrets', 'description' => 'Secret data', 'columns' => [ 'id' => 'integer', 'api_key' => 'string', ], 'relationships' => [], - ])); - - config()->set('sql-agent.knowledge.path', $this->knowledgePath); -}); - -afterEach(function () { - File::deleteDirectory($this->knowledgePath); + ]); }); describe('SemanticModelLoader with TableAccessControl', function () { diff --git a/tests/Unit/ServicesTest.php b/tests/Unit/ServicesTest.php index 1457120..61b7fca 100644 --- a/tests/Unit/ServicesTest.php +++ b/tests/Unit/ServicesTest.php @@ -5,7 +5,6 @@ use Knobik\SqlAgent\Services\ConnectionRegistry; use Knobik\SqlAgent\Services\ContextBuilder; use Knobik\SqlAgent\Services\KnowledgeLoader; -use Knobik\SqlAgent\Services\QueryPatternSearch; use Knobik\SqlAgent\Services\SchemaIntrospector; use Knobik\SqlAgent\Services\SemanticModelLoader; @@ -22,10 +21,7 @@ expect($loader)->toBeInstanceOf(SemanticModelLoader::class); }); - it('returns empty collection when no files exist', function () { - config(['sql-agent.knowledge.source' => 'files']); - config(['sql-agent.knowledge.path' => '/nonexistent/path']); - + it('returns empty collection when no data in database', function () { $loader = app(SemanticModelLoader::class); $tables = $loader->load(); @@ -33,23 +29,11 @@ }); it('formats empty result gracefully', function () { - config(['sql-agent.knowledge.source' => 'files']); - config(['sql-agent.knowledge.path' => '/nonexistent/path']); - $loader = app(SemanticModelLoader::class); $formatted = $loader->format(); expect($formatted)->toBe('No table metadata available.'); }); - - it('can load from database source', function () { - config(['sql-agent.knowledge.source' => 'database']); - - $loader = app(SemanticModelLoader::class); - $tables = $loader->load(); - - expect($tables)->toBeEmpty(); - }); }); describe('BusinessRulesLoader', function () { @@ -59,10 +43,7 @@ expect($loader)->toBeInstanceOf(BusinessRulesLoader::class); }); - it('returns empty collection when no files exist', function () { - config(['sql-agent.knowledge.source' => 'files']); - config(['sql-agent.knowledge.path' => '/nonexistent/path']); - + it('returns empty collection when no data in database', function () { $loader = app(BusinessRulesLoader::class); $rules = $loader->load(); @@ -70,9 +51,6 @@ }); it('formats empty result gracefully', function () { - config(['sql-agent.knowledge.source' => 'files']); - config(['sql-agent.knowledge.path' => '/nonexistent/path']); - $loader = app(BusinessRulesLoader::class); $formatted = $loader->format(); @@ -80,30 +58,6 @@ }); }); -describe('QueryPatternSearch', function () { - it('can be resolved from container', function () { - $search = app(QueryPatternSearch::class); - - expect($search)->toBeInstanceOf(QueryPatternSearch::class); - }); - - it('returns empty collection when no patterns exist', function () { - config(['sql-agent.knowledge.source' => 'database']); - - $search = app(QueryPatternSearch::class); - $patterns = $search->search('test query'); - - expect($patterns)->toBeEmpty(); - }); - - it('can set default limit', function () { - $search = app(QueryPatternSearch::class); - $result = $search->setDefaultLimit(5); - - expect($result)->toBeInstanceOf(QueryPatternSearch::class); - }); -}); - describe('SchemaIntrospector', function () { it('can be resolved from container', function () { $introspector = app(SchemaIntrospector::class); @@ -168,27 +122,15 @@ }); it('can build context', function () { - config(['sql-agent.knowledge.source' => 'database']); config(['sql-agent.learning.enabled' => false]); $builder = app(ContextBuilder::class); + $context = $builder->build('How many users?'); - try { - $context = $builder->build('How many users?'); - expect($context)->toBeInstanceOf(\Knobik\SqlAgent\Data\Context::class); - } catch (\BadMethodCallException $e) { - // Doctrine DBAL not available for this driver - test without runtime schema - $context = $builder->buildWithOptions( - question: 'How many users?', - includeRuntimeSchema: false, - ); - expect($context)->toBeInstanceOf(\Knobik\SqlAgent\Data\Context::class); - } + expect($context)->toBeInstanceOf(\Knobik\SqlAgent\Data\Context::class); }); it('can build minimal context', function () { - config(['sql-agent.knowledge.source' => 'database']); - $builder = app(ContextBuilder::class); $context = $builder->buildMinimal(); @@ -202,12 +144,9 @@ expect($builder->getSemanticLoader())->toBeInstanceOf(SemanticModelLoader::class); expect($builder->getRulesLoader())->toBeInstanceOf(BusinessRulesLoader::class); - expect($builder->getPatternSearch())->toBeInstanceOf(QueryPatternSearch::class); - expect($builder->getIntrospector())->toBeInstanceOf(SchemaIntrospector::class); }); it('can build context with custom options', function () { - config(['sql-agent.knowledge.source' => 'database']); config(['sql-agent.learning.enabled' => false]); $builder = app(ContextBuilder::class); @@ -217,7 +156,6 @@ includeBusinessRules: false, includeQueryPatterns: false, includeLearnings: false, - includeRuntimeSchema: false, ); expect($context->businessRules)->toBe(''); @@ -225,7 +163,6 @@ }); it('builds multi-connection context with per-connection sections', function () { - config(['sql-agent.knowledge.source' => 'database']); config(['sql-agent.learning.enabled' => false]); config(['sql-agent.database.connections' => [ 'sales' => [ @@ -243,25 +180,12 @@ app()->forgetInstance(ConnectionRegistry::class); $builder = app(ContextBuilder::class); - - try { - $context = $builder->build('How many users?'); - } catch (\BadMethodCallException $e) { - // Doctrine DBAL not available - skip runtime schema check - $this->markTestSkipped('Schema introspection not available for this driver.'); - } + $context = $builder->build('How many users?'); expect($context)->toBeInstanceOf(\Knobik\SqlAgent\Data\Context::class); - - // Runtime schema should contain per-connection headers - if ($context->runtimeSchema !== null) { - expect($context->runtimeSchema)->toContain('Connection: sales (Sales Database)'); - expect($context->runtimeSchema)->toContain('Connection: analytics (Analytics Database)'); - } }); it('includes business rules and learnings globally in multi-connection mode', function () { - config(['sql-agent.knowledge.source' => 'database']); config(['sql-agent.learning.enabled' => false]); config(['sql-agent.database.connections' => [ 'db1' => [ diff --git a/tests/Unit/Tools/SearchKnowledgeToolTest.php b/tests/Unit/Tools/SearchKnowledgeToolTest.php index 622b222..92fc915 100644 --- a/tests/Unit/Tools/SearchKnowledgeToolTest.php +++ b/tests/Unit/Tools/SearchKnowledgeToolTest.php @@ -60,7 +60,7 @@ it('searches query patterns', function () { $tool = app(SearchKnowledgeTool::class); - $result = json_decode($tool(query: 'users', type: 'patterns'), true); + $result = json_decode($tool(query: 'users', type: 'query_patterns'), true); expect($result['query_patterns'])->toHaveCount(1); expect($result['query_patterns'][0]['name'])->toBe('user_count'); @@ -105,7 +105,7 @@ $tool = app(SearchKnowledgeTool::class); - $result = json_decode($tool(query: 'users', type: 'patterns', limit: 3), true); + $result = json_decode($tool(query: 'users', type: 'query_patterns', limit: 3), true); expect($result['query_patterns'])->toHaveCount(3); }); @@ -125,4 +125,37 @@ expect($tool->parameters())->toHaveKey('limit'); expect($tool->requiredParameters())->toContain('query'); }); + + it('includes registered indexes in type enum', function () { + $tool = app(SearchKnowledgeTool::class); + + $params = $tool->parameters(); + $typeSchema = $params['type']; + + // The enum schema should contain the registered indexes + $json = json_encode($typeSchema); + + expect($json)->toContain('all'); + expect($json)->toContain('query_patterns'); + expect($json)->toContain('learnings'); + }); + + it('falls back to all for invalid type', function () { + $tool = app(SearchKnowledgeTool::class); + + $result = json_decode($tool(query: 'user', type: 'nonexistent_index'), true); + + expect($result)->toHaveKey('query_patterns'); + expect($result)->toHaveKey('learnings'); + }); + + it('skips learnings when learning is disabled', function () { + config(['sql-agent.learning.enabled' => false]); + + $tool = app(SearchKnowledgeTool::class); + + $result = json_decode($tool(query: 'user', type: 'all'), true); + + expect($result['learnings'])->toBeEmpty(); + }); });