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

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 build_user_message(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'))

  user_message = build_user_message(text, register_id: register_id, context: context)
  response     = client.messages.create(
    model:      model,
    max_tokens: 1024,
    system:     SYSTEM_PROMPT,
    tools:      [SEARCH_CONF_TOOL],
    messages:   [{ role: 'user', content: user_message }]
  )

  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