lex-transformer 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +16 -0
  3. data/.gitignore +3 -0
  4. data/.rubocop.yml +37 -21
  5. data/CHANGELOG.md +54 -0
  6. data/CLAUDE.md +135 -0
  7. data/Dockerfile +1 -1
  8. data/Gemfile +13 -0
  9. data/README.md +121 -19
  10. data/Rakefile +2 -0
  11. data/docker_deploy.rb +1 -0
  12. data/lex-transformer.gemspec +13 -17
  13. data/lib/legion/extensions/transformer/actors/transform.rb +20 -14
  14. data/lib/legion/extensions/transformer/client.rb +104 -0
  15. data/lib/legion/extensions/transformer/definitions.rb +56 -0
  16. data/lib/legion/extensions/transformer/engines/base.rb +19 -0
  17. data/lib/legion/extensions/transformer/engines/erb.rb +37 -0
  18. data/lib/legion/extensions/transformer/engines/jsonpath.rb +41 -0
  19. data/lib/legion/extensions/transformer/engines/liquid.rb +31 -0
  20. data/lib/legion/extensions/transformer/engines/llm.rb +153 -0
  21. data/lib/legion/extensions/transformer/engines/registry.rb +51 -0
  22. data/lib/legion/extensions/transformer/engines/static.rb +21 -0
  23. data/lib/legion/extensions/transformer/helpers/schema_validator.rb +48 -0
  24. data/lib/legion/extensions/transformer/runners/transform.rb +79 -52
  25. data/lib/legion/extensions/transformer/transport/exchanges/task.rb +10 -4
  26. data/lib/legion/extensions/transformer/transport/messages/message.rb +27 -17
  27. data/lib/legion/extensions/transformer/transport/queues/transform.rb +12 -6
  28. data/lib/legion/extensions/transformer/transport.rb +19 -13
  29. data/lib/legion/extensions/transformer/version.rb +3 -1
  30. data/lib/legion/extensions/transformer.rb +3 -0
  31. metadata +29 -76
  32. data/CODE_OF_CONDUCT.md +0 -74
  33. data/Gemfile.lock +0 -58
  34. data/bitbucket-pipelines.yml +0 -19
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'engines/registry'
4
+ require_relative 'helpers/schema_validator'
5
+ require_relative 'definitions'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Transformer
10
+ class Client
11
+ def transform(payload:, transformation: nil, engine: nil, schema: nil, engine_options: {}, name: nil)
12
+ return transform_by_name(name: name, payload: payload, engine_options: engine_options) if transformation.nil? && name
13
+
14
+ eng = resolve_engine(engine, transformation)
15
+ rendered = eng.render(transformation, payload, **engine_options)
16
+ rendered = parse_rendered(rendered)
17
+
18
+ return rendered if rendered.is_a?(Hash) && rendered[:success] == false
19
+
20
+ if schema
21
+ validation = Helpers::SchemaValidator.validate(schema: schema, data: rendered)
22
+ return { success: false, status: 'transformer.validation_failed', errors: validation[:errors] } unless validation[:valid]
23
+ end
24
+
25
+ { success: true, result: rendered }
26
+ end
27
+
28
+ def transform_chain(steps:, payload:)
29
+ result = payload.dup
30
+ steps.each do |step|
31
+ eng = resolve_engine(step[:engine], step[:transformation])
32
+ step_opts = step[:engine_options] || {}
33
+ rendered = eng.render(step[:transformation], result, **step_opts)
34
+ rendered = parse_rendered(rendered)
35
+
36
+ return rendered if rendered.is_a?(Hash) && rendered[:success] == false
37
+
38
+ if step[:schema]
39
+ validation = Helpers::SchemaValidator.validate(schema: step[:schema], data: rendered)
40
+ return { success: false, status: 'transformer.validation_failed', errors: validation[:errors] } unless validation[:valid]
41
+ end
42
+
43
+ if rendered.is_a?(Hash)
44
+ result = result.merge({ args: rendered }.merge(rendered))
45
+ else
46
+ result[:args] = rendered
47
+ end
48
+ end
49
+ { success: true, result: result }
50
+ end
51
+
52
+ private
53
+
54
+ def transform_by_name(name:, payload:, engine_options: {})
55
+ definition = Definitions.fetch(name)
56
+ return { success: false, error: 'definition_not_found' } unless definition
57
+
58
+ if definition[:conditions] && conditioner_available?
59
+ cond_result = evaluate_conditions(definition[:conditions], payload)
60
+ return { success: false, reason: 'conditions_not_met' } unless cond_result
61
+ end
62
+
63
+ merged_opts = Definitions.merge_options(definition, **engine_options)
64
+
65
+ transform(
66
+ transformation: definition[:transformation],
67
+ payload: payload,
68
+ engine: definition[:engine],
69
+ schema: definition[:schema],
70
+ engine_options: merged_opts
71
+ )
72
+ end
73
+
74
+ def conditioner_available?
75
+ defined?(Legion::Extensions::Conditioner::Client)
76
+ end
77
+
78
+ def evaluate_conditions(conditions, payload)
79
+ client = Legion::Extensions::Conditioner::Client.new
80
+ result = client.evaluate(conditions: conditions, values: payload)
81
+ result[:passed]
82
+ rescue StandardError
83
+ true
84
+ end
85
+
86
+ def resolve_engine(engine_name, transformation)
87
+ if engine_name
88
+ Engines::Registry.fetch(engine_name)
89
+ else
90
+ Engines::Registry.detect(transformation)
91
+ end
92
+ end
93
+
94
+ def parse_rendered(rendered)
95
+ return rendered unless rendered.is_a?(String)
96
+
97
+ Legion::JSON.load(rendered)
98
+ rescue StandardError
99
+ rendered
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Transformer
6
+ class Definitions
7
+ class << self
8
+ def fetch(name)
9
+ defns = load_definitions
10
+ return nil unless defns&.key?(name)
11
+
12
+ symbolize_definition(defns[name])
13
+ end
14
+
15
+ def names
16
+ defns = load_definitions
17
+ return [] unless defns
18
+
19
+ defns.keys
20
+ end
21
+
22
+ def merge_options(definition, **overrides)
23
+ base = definition[:engine_options] || {}
24
+ base.merge(overrides)
25
+ end
26
+
27
+ private
28
+
29
+ def load_definitions
30
+ return nil unless defined?(Legion::Settings)
31
+
32
+ Legion::Settings.dig('lex-transformer', 'definitions')
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ def symbolize_definition(raw)
38
+ defn = {}
39
+ defn[:transformation] = raw['transformation'] || raw[:transformation]
40
+ defn[:engine] = (raw['engine'] || raw[:engine])&.to_sym
41
+ defn[:engine_options] = symbolize_hash(raw['engine_options'] || raw[:engine_options] || {})
42
+ defn[:schema] = raw['schema'] || raw[:schema]
43
+ defn[:conditions] = raw['conditions'] || raw[:conditions]
44
+ defn
45
+ end
46
+
47
+ def symbolize_hash(hash)
48
+ return {} unless hash.is_a?(Hash)
49
+
50
+ hash.transform_keys(&:to_sym)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Transformer
6
+ module Engines
7
+ class Base
8
+ def render(template, payload, **_opts)
9
+ raise NotImplementedError, "#{self.class}#render must be implemented"
10
+ end
11
+
12
+ def name
13
+ raise NotImplementedError, "#{self.class}#name must be implemented"
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tilt'
4
+ require_relative 'base'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Transformer
9
+ module Engines
10
+ class Erb < Base
11
+ def name
12
+ :erb
13
+ end
14
+
15
+ def render(template, payload, **_opts)
16
+ tilt_template = Tilt['erb'].new { template }
17
+ variables = build_variables(template, payload)
18
+ tilt_template.render(Object.new, variables)
19
+ end
20
+
21
+ private
22
+
23
+ def build_variables(template, payload)
24
+ variables = { **payload }
25
+ variables[:crypt] = Legion::Crypt if defined?(Legion::Crypt) && template.include?('crypt')
26
+ variables[:settings] = Legion::Settings if defined?(Legion::Settings) && template.include?('settings')
27
+ variables[:cache] = Legion::Cache if defined?(Legion::Cache) && template.include?('cache')
28
+ if payload.key?(:task_id) && template.include?('task') && defined?(Legion::Data::Model::Task)
29
+ variables[:task] = Legion::Data::Model::Task[payload[:task_id]]
30
+ end
31
+ variables
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Transformer
8
+ module Engines
9
+ class Jsonpath < Base
10
+ def name
11
+ :jsonpath
12
+ end
13
+
14
+ def render(expression, payload, **_opts)
15
+ extract(expression, payload)
16
+ end
17
+
18
+ private
19
+
20
+ def extract(path, data)
21
+ path = path.delete_prefix('$.') if path.start_with?('$.')
22
+ segments = path.split('.')
23
+ result = data
24
+ segments.each do |segment|
25
+ case result
26
+ when Hash
27
+ key = result.key?(segment.to_sym) ? segment.to_sym : segment
28
+ result = result[key]
29
+ when Array
30
+ result = result[segment.to_i]
31
+ else
32
+ return nil
33
+ end
34
+ end
35
+ result
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Transformer
8
+ module Engines
9
+ class Liquid < Base
10
+ def name
11
+ :liquid
12
+ end
13
+
14
+ def render(template, payload, **_opts)
15
+ require 'liquid'
16
+ liquid_template = ::Liquid::Template.parse(template)
17
+ liquid_template.render(stringify_keys(payload))
18
+ end
19
+
20
+ private
21
+
22
+ def stringify_keys(hash)
23
+ hash.each_with_object({}) do |(k, v), result|
24
+ result[k.to_s] = v.is_a?(Hash) ? stringify_keys(v) : v
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+ require_relative 'base'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Transformer
9
+ module Engines
10
+ class Llm < Base
11
+ AUTH_ERRORS = /auth|credentials|forbidden|api.key|unauthorized/i
12
+ RETRY_SLEEP = 1
13
+
14
+ def name
15
+ :llm
16
+ end
17
+
18
+ def render(prompt, payload, **opts)
19
+ raise 'Legion::LLM is not available or not started' unless llm_available?
20
+
21
+ @last_raw = nil
22
+ resolved = resolve_options(opts)
23
+ max_retries = resolved.delete(:max_retries)
24
+ attempts = 0
25
+
26
+ loop do
27
+ result = attempt_render(prompt, payload, resolved, attempts)
28
+ return result unless result == :retry
29
+
30
+ attempts += 1
31
+ return build_failure if attempts > max_retries
32
+
33
+ sleep(RETRY_SLEEP)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def llm_available?
40
+ defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
41
+ end
42
+
43
+ def resolve_options(opts)
44
+ defaults = settings_defaults
45
+ resolved = defaults.merge(opts)
46
+ resolved[:max_retries] = (resolved[:max_retries] || 1).to_i
47
+ resolved
48
+ end
49
+
50
+ def settings_defaults
51
+ return {} unless defined?(Legion::Settings)
52
+
53
+ settings = begin
54
+ Legion::Settings.dig('lex-transformer', 'llm')
55
+ rescue StandardError
56
+ nil
57
+ end
58
+ return {} unless settings.is_a?(Hash)
59
+
60
+ settings.transform_keys(&:to_sym)
61
+ end
62
+
63
+ def attempt_render(prompt, payload, opts, attempt)
64
+ chat = call_llm(prompt, payload, opts, attempt)
65
+ content = extract_response(chat)
66
+ validate_json(content)
67
+ content
68
+ rescue Timeout::Error, IOError, Errno::ECONNREFUSED, Errno::ECONNRESET
69
+ :retry
70
+ rescue ::JSON::ParserError
71
+ @last_raw = content
72
+ :retry
73
+ rescue RuntimeError => e
74
+ raise if auth_error?(e)
75
+
76
+ { success: false, error: e.class.to_s, message: e.message }
77
+ rescue StandardError => e
78
+ { success: false, error: e.class.to_s, message: e.message }
79
+ end
80
+
81
+ def call_llm(prompt, payload, opts, attempt)
82
+ context = Legion::JSON.dump(payload)
83
+
84
+ return call_structured(prompt, context, opts) if opts[:structured] && opts[:schema] && structured_available?
85
+
86
+ full_prompt = build_prompt(prompt, context, attempt)
87
+ full_prompt = inject_schema_into_prompt(full_prompt, opts[:schema]) if opts[:structured] && opts[:schema]
88
+
89
+ llm_opts = build_llm_opts(opts)
90
+ Legion::LLM.chat(message: full_prompt, **llm_opts)
91
+ end
92
+
93
+ def structured_available?
94
+ Legion::LLM.respond_to?(:structured)
95
+ end
96
+
97
+ def call_structured(prompt, context, opts)
98
+ llm_opts = build_llm_opts(opts)
99
+ Legion::LLM.structured(
100
+ message: "#{prompt}\n\nPayload:\n```json\n#{context}\n```",
101
+ schema: opts[:schema],
102
+ **llm_opts
103
+ )
104
+ end
105
+
106
+ def inject_schema_into_prompt(prompt, schema)
107
+ schema_json = Legion::JSON.dump(schema)
108
+ "#{prompt}\n\nYour response MUST conform to this JSON schema:\n```json\n#{schema_json}\n```"
109
+ end
110
+
111
+ def build_prompt(prompt, context, attempt)
112
+ base = "#{prompt}\n\nPayload:\n```json\n#{context}\n```\n\nRespond with valid JSON only. No explanation, no markdown fences."
113
+ return base unless attempt.positive?
114
+
115
+ "#{base}\n\nIMPORTANT: Your previous response was not valid JSON. Return ONLY a valid JSON object or array, nothing else."
116
+ end
117
+
118
+ def extract_response(chat)
119
+ content = chat.respond_to?(:content) ? chat.content : chat.to_s
120
+ content = content.strip
121
+ content = content.sub(/\A```(?:json)?\n?/, '').sub(/\n?```\z/, '') if content.start_with?('```')
122
+ content
123
+ end
124
+
125
+ def validate_json(content)
126
+ ::JSON.parse(content)
127
+ end
128
+
129
+ def auth_error?(error)
130
+ AUTH_ERRORS.match?(error.message)
131
+ end
132
+
133
+ def build_llm_opts(opts)
134
+ llm_opts = {}
135
+ llm_opts[:model] = opts[:model] if opts[:model]
136
+ llm_opts[:provider] = opts[:provider] if opts[:provider]
137
+ llm_opts[:temperature] = opts[:temperature] if opts[:temperature]
138
+ llm_opts[:system_prompt] = opts[:system_prompt] if opts[:system_prompt]
139
+ llm_opts
140
+ end
141
+
142
+ def build_failure
143
+ if @last_raw
144
+ { success: false, error: 'invalid_json', message: 'max retries exhausted', raw: @last_raw }
145
+ else
146
+ { success: false, error: 'timeout', message: 'max retries exhausted' }
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'erb'
4
+ require_relative 'static'
5
+ require_relative 'liquid'
6
+ require_relative 'jsonpath'
7
+ require_relative 'llm'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Transformer
12
+ module Engines
13
+ class Registry
14
+ ENGINES = {}.freeze
15
+
16
+ class << self
17
+ def register(engine_class)
18
+ @engines ||= {}
19
+ instance = engine_class.new
20
+ @engines[instance.name] = instance
21
+ @engines[instance.name.to_s] = instance
22
+ end
23
+
24
+ def fetch(name)
25
+ @engines ||= {}
26
+ @engines[name.to_sym] || @engines[name.to_s] || raise(ArgumentError, "Unknown engine: #{name}")
27
+ end
28
+
29
+ def detect(template)
30
+ if template.is_a?(String) && (template.include?('<%') || template.include?('%>'))
31
+ fetch(:erb)
32
+ else
33
+ fetch(:static)
34
+ end
35
+ end
36
+
37
+ def reset!
38
+ @engines = {}
39
+ end
40
+ end
41
+
42
+ register(Erb)
43
+ register(Static)
44
+ register(Liquid)
45
+ register(Jsonpath)
46
+ register(Llm)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Transformer
8
+ module Engines
9
+ class Static < Base
10
+ def name
11
+ :static
12
+ end
13
+
14
+ def render(template, _payload, **_opts)
15
+ template
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Transformer
6
+ module Helpers
7
+ class SchemaValidator
8
+ class << self
9
+ def validate(schema:, data:)
10
+ return { valid: true } if schema.nil? || schema.empty?
11
+
12
+ errors = []
13
+ data ||= {}
14
+
15
+ check_required_keys(schema[:required_keys], data, errors)
16
+ check_types(schema[:types], data, errors)
17
+
18
+ errors.empty? ? { valid: true } : { valid: false, errors: errors }
19
+ end
20
+
21
+ private
22
+
23
+ def check_required_keys(required_keys, data, errors)
24
+ return if required_keys.nil? || required_keys.empty?
25
+
26
+ required_keys.each do |key|
27
+ errors << "missing required key: #{key}" unless data.key?(key)
28
+ end
29
+ end
30
+
31
+ def check_types(types, data, errors)
32
+ return if types.nil? || types.empty?
33
+
34
+ types.each do |key, expected_type|
35
+ next unless data.key?(key)
36
+
37
+ actual = data[key]
38
+ next if actual.is_a?(expected_type)
39
+
40
+ errors << "#{key} expected #{expected_type}, got #{actual.class}"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end