Module: Ecoportal::API::GraphQL::Input::SearchConf::AIGenerator
- Defined in:
- lib/ecoportal/api/graphql/input/search_conf/ai_generator.rb
Overview
AI-assisted filter generation — builds a SearchConf from a natural-language description using Claude (Anthropic API) with structured tool use output.
Requires the anthropic gem to be available in the caller's environment.
No hard dependency is added to ecoportal-api-graphql's gemspec.
Usage: require 'anthropic'
conf = SearchConf.from_description(
"find active pages in the Safety register updated in the last month",
api_key: ENV['ANTHROPIC_API_KEY'],
register_id: 'REG_SAFETY_123'
)
# => #
# Then use it: org.pages(searchConf: conf.to_h, first: 50)
Options: api_key: Anthropic API key (default: ENV['ANTHROPIC_API_KEY']) model: Claude model ID (default: 'claude-sonnet-4-6') register_id: Scope results to this register (adds register_filter) context: Hash of extra context passed to the prompt (e.g. available states) validate: Boolean — if true, raises on ambiguous/empty result (default: false)
Constant Summary collapse
- TOOL_NAME =
'build_search_conf'.freeze
- DEFAULT_MODEL =
'claude-sonnet-4-6'.freeze
- SYSTEM_PROMPT =
<<~PROMPT.freeze You are a search query translator for the EcoPortal GraphQL API. Convert natural-language descriptions into structured searchConf filter specifications. ## Available filter operations (snake_case strings — case-sensitive) | operation | params | notes | |---------------------|---------------------------------------------|--------------------------------| | exact_filter | key, value | equality match | | one_of_filter | key, values (array) | any of N values | | none_of_filter | key, values (array) | exclude all values | | date_filter | key, gte, lte, time_zone (UTC default) | ISO8601 or mode | | date_filter (mode) | key, mode, time_zone | modes: last_month, last_week, current_year, past, future, etc. | | register_filter | ids (array) | scope to register(s) | | has_any_filter | key | field has any non-empty value | | match_filter | key, value | partial text match | | and_filter | filters (array of filter objects) | explicit AND group | | or_filter | filters (array of filter objects) | OR group | | boolean_filter | key, value (true/false) | boolean field | | numeric_range_filter| key, gte, lte | numeric bounds | | isnt_exact_filter | key, value | negated equality | ## Common page field names (key values) | fieldName | type | common values | |-----------------|---------|----------------------------------------| | state | string | "active", "inactive", "complete" | | external_id | string | unique identifier | | name | string | page name (use match_filter for partial)| | creator_id | string | PersonMember ID | | created_at | date | ISO8601 datetime | | updated_at | date | ISO8601 datetime | | all_location_ids| string | location node ID (includes ancestors) | | location_id | string | exact location node ID | | other_tags | string | tag string values | | archived | boolean | true/false | | draft | boolean | true/false | ## Rules - Top-level filters array is implicitly AND — no need to wrap in and_filter unless nesting inside or_filter - Use register_filter when a specific register is mentioned or provided in context - Use date_filter with mode (e.g. "last_month") for relative dates; ISO8601 for absolute dates - Default sort is created_at desc unless the user specifies otherwise - If the description is too vague to produce a useful filter, produce an empty filters array and explain in the reasoning field - snake_case operation names are MANDATORY — CamelCase returns no results silently Call the build_search_conf tool with your result. PROMPT
- SEARCH_CONF_TOOL =
JSON Schema for the tool — Claude must call this with the filter spec.
{ name: TOOL_NAME, description: 'Output a structured searchConf filter specification', input_schema: { type: 'object', required: %w[filters], properties: { reasoning: { type: 'string', description: 'Brief explanation of the chosen filters' }, register_ids: { type: 'array', description: 'Register IDs to scope the search (leave empty for org-wide)', items: { type: 'string' } }, filters: { type: 'array', description: 'Array of filter objects (top-level is implicit AND)', items: { type: 'object', required: %w[operation params], properties: { operation: { type: 'string', description: 'Filter operation name (snake_case)' }, params: { type: 'object', description: 'Operation parameters (key, value, ids, filters, gte, lte, etc.)' } } } }, sorters: { type: 'array', items: { type: 'object', required: %w[key direction], properties: { key: { type: 'string' }, direction: { type: 'string', enum: %w[asc desc] } } } }, query: { type: 'string', description: 'Full-text search string (optional)' } } } }.freeze
Class Method Summary collapse
- .apply_filters(conf, filters) ⇒ Object
- .apply_query(conf, query) ⇒ Object
- .apply_register(conf, result, register_id) ⇒ Object
- .apply_sorters(conf, sorters) ⇒ Object
- .build_search_conf(result, register_id:) ⇒ Object
- .build_user_message(text, register_id:, context:) ⇒ Object
- .extract_tool_result(response) ⇒ Object
-
.from_description(text, api_key: nil, model: DEFAULT_MODEL, register_id: nil, context: {}, validate: false) ⇒ Object
Build a SearchConf from a natural-language description via Claude API.
- .require_anthropic! ⇒ Object
- .symbolize(hash) ⇒ Object
Class Method Details
.apply_filters(conf, filters) ⇒ Object
154 155 156 157 158 |
# File 'lib/ecoportal/api/graphql/input/search_conf/ai_generator.rb', line 154 def apply_filters(conf, filters) Array(filters).reduce(conf) do |c, f| c.filter(SearchConf::RawFilter.new(f['operation'], **symbolize(f['params'] || {}))) end end |
.apply_query(conf, query) ⇒ Object
166 167 168 169 170 |
# File 'lib/ecoportal/api/graphql/input/search_conf/ai_generator.rb', line 166 def apply_query(conf, query) return conf unless query && !query.strip.empty? conf.query(query) end |
.apply_register(conf, result, register_id) ⇒ Object
143 144 145 146 147 148 149 150 151 152 |
# File 'lib/ecoportal/api/graphql/input/search_conf/ai_generator.rb', line 143 def apply_register(conf, result, register_id) ids = result['register_ids']&.compact&.reject(&:empty?) if ids && !ids.empty? conf.filter(SearchConf::Register.new(*ids)) elsif register_id conf.filter(SearchConf::Register.new(register_id)) else conf end end |
.apply_sorters(conf, sorters) ⇒ Object
160 161 162 163 164 |
# File 'lib/ecoportal/api/graphql/input/search_conf/ai_generator.rb', line 160 def apply_sorters(conf, sorters) Array(sorters).reduce(conf) do |c, s| c.sort(s['key'].to_sym, s['direction']&.to_sym || :desc) end end |
.build_search_conf(result, register_id:) ⇒ Object
135 136 137 138 139 140 141 |
# File 'lib/ecoportal/api/graphql/input/search_conf/ai_generator.rb', line 135 def build_search_conf(result, register_id:) conf = SearchConf.new conf = apply_register(conf, result, register_id) conf = apply_filters(conf, result['filters']) conf = apply_sorters(conf, result['sorters']) apply_query(conf, result['query']) end |
.build_user_message(text, register_id:, context:) ⇒ Object
121 122 123 124 125 126 |
# File 'lib/ecoportal/api/graphql/input/search_conf/ai_generator.rb', line 121 def (text, register_id:, context:) parts = ["Convert this description to a searchConf filter: #{text.inspect}"] parts << "Register ID to scope results: #{register_id}" if register_id context.each { |k, v| parts << "Context — #{k}: #{v}" } parts.join("\n") end |
.extract_tool_result(response) ⇒ Object
128 129 130 131 132 133 |
# File 'lib/ecoportal/api/graphql/input/search_conf/ai_generator.rb', line 128 def extract_tool_result(response) tool_use = response.content.find { |b| b.type == 'tool_use' && b.name == TOOL_NAME } raise "AI did not call #{TOOL_NAME} — response: #{response.content.map(&:type)}" unless tool_use tool_use.input end |
.from_description(text, api_key: nil, model: DEFAULT_MODEL, register_id: nil, context: {}, validate: false) ⇒ Object
Build a SearchConf from a natural-language description via Claude API.
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/ecoportal/api/graphql/input/search_conf/ai_generator.rb', line 87 def from_description(text, api_key: nil, model: DEFAULT_MODEL, register_id: nil, context: {}, validate: false) require_anthropic! client = ::Anthropic::Client.new(api_key: api_key || ENV.fetch('ANTHROPIC_API_KEY')) = (text, register_id: register_id, context: context) response = client..create( model: model, max_tokens: 1024, system: SYSTEM_PROMPT, tools: [SEARCH_CONF_TOOL], messages: [{ role: 'user', content: }] ) result = extract_tool_result(response) conf = build_search_conf(result, register_id: register_id) if validate && conf.to_h.empty? raise ArgumentError, "AI could not produce filters for: #{text.inspect}. " \ "Reasoning: #{result['reasoning']}" end conf end |
.require_anthropic! ⇒ Object
113 114 115 116 117 118 119 |
# File 'lib/ecoportal/api/graphql/input/search_conf/ai_generator.rb', line 113 def require_anthropic! require 'anthropic' rescue LoadError raise LoadError, "SearchConf.from_description requires the 'anthropic' gem. " \ "Add `gem 'anthropic'` to your Gemfile and run `bundle install`." end |
.symbolize(hash) ⇒ Object
172 173 174 |
# File 'lib/ecoportal/api/graphql/input/search_conf/ai_generator.rb', line 172 def symbolize(hash) hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } end |