omen 0.1.0 → 0.2.1

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.
@@ -0,0 +1,49 @@
1
+ # The one SELECT Claude wrote, held by Postgres and by what it grants the role running it.
2
+ class Omen::Query
3
+ # Rails writes every timestamp in UTC, which libpq would otherwise read as a local time.
4
+ UTC = { oid: 1114, name: 'timestamp', format: 0 }
5
+
6
+ # @param sql [String] the statement Claude answered with.
7
+ def initialize(sql)
8
+ @sql = sql
9
+ end
10
+
11
+ # A savepoint, so a statement Postgres rejects leaves the surrounding transaction usable.
12
+ # @return [Hash] the rows, and the encrypted column each header of theirs came from.
13
+ def answer
14
+ record.connected_to role: Omen.config.reading_role do
15
+ record.transaction requires_new: true do
16
+ record.with_connection { |connection| run connection }
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def record = Omen.config.record
24
+
25
+ def run(connection)
26
+ answered = Omen::Role.new(connection).around { executed connection }
27
+ { result: answered.to_a.first(cap), provenance: Omen::Column.of(answered, connection) }
28
+ end
29
+
30
+ def executed(connection)
31
+ raw = connection.raw_connection
32
+ result = raw.exec_params capped, []
33
+ result.type_map = typed raw
34
+ result
35
+ end
36
+
37
+ def typed(raw)
38
+ PG::BasicTypeMapForResults.new(raw).tap do |map|
39
+ map.default_type_map = PG::TypeMapAllStrings.new
40
+ map.add_coder PG::TextDecoder::TimestampUtc.new(**UTC)
41
+ end
42
+ end
43
+
44
+ def capped = "SELECT * FROM (#{statement}) AS answer LIMIT #{cap}"
45
+
46
+ def statement = @sql.strip.delete_suffix ';'
47
+
48
+ def cap = Omen.config.maximum_rows + 1
49
+ end
@@ -0,0 +1,13 @@
1
+ # A question somebody typed. There is no asked? to check: a question is asked by definition.
2
+ class Omen::Question < Omen.config.record
3
+ include Omen::Spoken
4
+
5
+ belongs_to :reading, counter_cache: :questions_count, touch: true
6
+ has_one :answer, dependent: :delete
7
+
8
+ # A question is what sets a run going, and an answer is what a run leaves behind.
9
+ after_create_commit -> { reading.run_later }
10
+
11
+ # @return [String] the side of the conversation this was said on, in the words the API uses.
12
+ def role = 'user'
13
+ end
@@ -0,0 +1,18 @@
1
+ # A thread of questions somebody asks Claude about the data this app holds.
2
+ class Omen::Reading < Omen.config.record
3
+ include Omen::Asked, Omen::Executed, Omen::Stated
4
+
5
+ has_many :questions, -> { order :id }, dependent: :destroy
6
+
7
+ # What the reading is opened with: a reading nobody asked anything is a reading of nothing.
8
+ attribute :question, :string
9
+
10
+ validates :question, presence: true, on: :create
11
+
12
+ after_create -> { ask question }
13
+
14
+ performs :run
15
+
16
+ # @return [String] the default representation (used in views).
17
+ def to_s = questions.first&.text.to_s.truncate 80
18
+ end
@@ -0,0 +1,30 @@
1
+ # Extends Omen::Answer with the plaintext behind the encrypted columns of its rows.
2
+ module Omen::Revealed extend ActiveSupport::Concern
3
+ # How Rails' encryption opens an envelope, in either case an expression may have left it in.
4
+ CIPHERTEXT = [ '{"p":', '{"P":' ]
5
+
6
+ # @return [Array<Hash>] the rows of the answer a page shows, read back where that is allowed.
7
+ def shown
8
+ @shown ||= result.first(Omen.config.maximum_rows).map { |row| combined revealed row }
9
+ end
10
+
11
+ private
12
+
13
+ def combined(row) = combinations.inject(row) { |left, one| one.applied left }
14
+
15
+ def combinations = @combinations ||= Omen::Combination.all(combine, result.first.to_h.keys)
16
+
17
+ def revealed(row) = row.to_h { |header, value| [ header, plain(header, value) ] }
18
+
19
+ def plain(header, value)
20
+ if (column = columns[provenance[header]])
21
+ column.read value
22
+ elsif value.to_s.start_with?(*CIPHERTEXT)
23
+ Omen::Column::HIDDEN
24
+ else
25
+ value
26
+ end
27
+ end
28
+
29
+ def columns = @columns ||= Omen::Column.all.index_by(&:name)
30
+ end
@@ -0,0 +1,29 @@
1
+ # The Postgres role a reading's statement runs as, which is refused the reading's own tables.
2
+ class Omen::Role
3
+ # Raised where the database has no such role, or has not granted it to the connecting user.
4
+ class Unavailable < StandardError; end
5
+
6
+ # Ours to write, since it quotes no row: what an asker is told when it was the setup that failed.
7
+ MISCONFIGURED = 'This app is misconfigured, not the question -- ask an engineer to install ' \
8
+ 'the read-only role a statement runs as.'
9
+
10
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] the one to switch.
11
+ def initialize(connection)
12
+ @connection = connection
13
+ end
14
+
15
+ # Given up before anything of ours runs; a statement Postgres refused rolls it back instead.
16
+ # @return [Object] whatever the block answered.
17
+ def around
18
+ enter
19
+ yield.tap { @connection.execute 'SET LOCAL ROLE NONE' }
20
+ end
21
+
22
+ private
23
+
24
+ def enter
25
+ @connection.execute "SET LOCAL ROLE #{@connection.quote_table_name Omen.config.narrow_role}"
26
+ rescue ActiveRecord::StatementInvalid
27
+ raise Unavailable, MISCONFIGURED
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ # What Claude writes its SQL against: the app's own schema, less the tables a reading is kept in.
2
+ class Omen::Schema
3
+ # A plain index is a note about speed; a unique one is a fact about the rows, so it stays.
4
+ HINT = /^\s*t\.index (?!.*unique: true).*\n/
5
+
6
+ # @return [String] the schema, without this feature's own tables, their keys or their enum.
7
+ def text = without_orphan_enums hidden.inject(source) { |left, name| without_table left, name }
8
+
9
+ private
10
+
11
+ def source = File.read(Omen.config.schema).gsub HINT, ''
12
+
13
+ def hidden = Omen.tables
14
+
15
+ def without_table(text, name)
16
+ text.gsub(/^ create_table "#{name}".*?\n end\n\n?/m, '')
17
+ .gsub(/^ add_foreign_key ("#{name}"|"\w+", "#{name}").*\n/, '')
18
+ end
19
+
20
+ def without_orphan_enums(text)
21
+ # Derived rather than named, so a type the cut tables shared with another survives
22
+ text.gsub(/^ create_enum "(\w+)".*\n/) do |line|
23
+ text.include?(%(enum_type: "#{Regexp.last_match 1}")) ? line : ''
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # Extends a question and an answer with the prose said in it, in the blocks Claude speaks in.
2
+ module Omen::Spoken extend ActiveSupport::Concern
3
+ # @return [String] everything said in prose, which on an answer may be nothing.
4
+ def text = content.select { |block| block['type'] == 'text' }.pluck('text').join "\n"
5
+ end
@@ -0,0 +1,21 @@
1
+ # Extends Omen::Reading to have a status of its own, rather than one a host shares out.
2
+ module Omen::Stated extend ActiveSupport::Concern
3
+ # The states a reading passes through. The reading...
4
+ STATUSES = [
5
+ :unstarted, # ... has been asked something and has nothing back yet (default)
6
+ :started, # ... is being answered right now, by whichever run holds the claim
7
+ :completed, # ... has been answered, so another question may follow
8
+ :failed, # ... could not be answered at all, and is the asker's to try again
9
+ ]
10
+
11
+ # How long a run holds its claim before another may take the reading over.
12
+ STALLED_AFTER = 10.minutes
13
+
14
+ included do
15
+ enum :status, Hash[STATUSES.map { |status| [status, status] }]
16
+ end
17
+
18
+ private
19
+
20
+ def fresh? = updated_at.after? STALLED_AFTER.ago
21
+ end
@@ -0,0 +1,13 @@
1
+ class CreateOmenReadings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_enum :omen_status, Omen::Stated::STATUSES
4
+
5
+ create_table :omen_readings do |t|
6
+ t.enum :status, enum_type: :omen_status, default: Omen::Stated::STATUSES.first, null: false
7
+ t.integer :input_usage, default: 0, null: false
8
+ t.integer :output_usage, default: 0, null: false
9
+ t.integer :questions_count, default: 0, null: false
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ class CreateOmenQuestions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :omen_questions do |t|
4
+ t.jsonb :content, default: [], null: false
5
+ t.references :reading, null: false, foreign_key: { to_table: :omen_readings }
6
+ t.timestamps
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ class CreateOmenAnswers < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :omen_answers do |t|
4
+ t.jsonb :content, default: [], null: false
5
+ t.jsonb :result, default: [], null: false
6
+ t.jsonb :provenance, default: {}, null: false
7
+ t.integer :input_usage, default: 0, null: false
8
+ t.integer :output_usage, default: 0, null: false
9
+ t.references :question, null: false, index: { unique: true },
10
+ foreign_key: { to_table: :omen_questions }
11
+ t.string :stop_reason
12
+ t.string :error
13
+ t.timestamps
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ require 'rails/generators/active_record'
2
+
3
+ module Omen
4
+ module Generators
5
+ # Everything installing this gem writes into a host: three migrations and one initializer.
6
+ # Not the roles or the database function, which are a rake task, because a copy of those
7
+ # would drift from what the gem goes on to expect and nothing would detect it.
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ # Both, since the migrations are shipped where they run from rather than as templates.
12
+ # @return [Array<String>] where a file being copied is looked for.
13
+ def self.source_paths
14
+ [ File.expand_path('templates', __dir__), Omen::Engine.root.join('db/migrate').to_s ]
15
+ end
16
+
17
+ # Copied rather than read off the gem, so the host owns the files and their timestamps.
18
+ # @return [void]
19
+ def copy_migrations
20
+ Dir.children(Omen::Engine.root.join('db/migrate')).sort.each do |name|
21
+ migration_template name, "db/migrate/#{name.split('_', 2).last}"
22
+ end
23
+ end
24
+
25
+ # @return [void]
26
+ def copy_initializer
27
+ template 'omen.rb', 'config/initializers/omen.rb'
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # Be sure to restart your server when you modify this file.
2
+ # Every setting Omen has is listed with the default it takes where the line is left out, so
3
+ # an app that accepts all of them can delete the block. What an app has to provide outside
4
+ # this file -- the read-only connection, the narrow role and the database function -- is in
5
+ # the gem's README, and none of it is optional.
6
+
7
+ Omen.configure do |config|
8
+ # The Active Record whose descendants are read for encrypted columns, and whose connection
9
+ # roles a statement is run through. A name, since nothing is autoloaded while this runs.
10
+ # config.record_class = 'ApplicationRecord'
11
+
12
+ # The Rails connection role a statement is read through. This app has to declare it with
13
+ # connects_to: Omen raises rather than falling back to a role that could write.
14
+ # config.reading_role = :reading
15
+
16
+ # The Postgres role the statement itself runs as, narrower again than the connection's own.
17
+ # Created by bin/rails db:omen:grant, which is also what revokes Omen's own tables from it.
18
+ # config.narrow_role = 'omen_inquirer'
19
+
20
+ # config.claude_model = 'claude-opus-5'
21
+
22
+ # How many rows of one answer a page shows. One more than this is read, so the page can say
23
+ # there are more without counting them.
24
+ # config.maximum_rows = 100
25
+
26
+ # Left unset, the Anthropic SDK resolves ANTHROPIC_API_KEY and its own wider chain.
27
+ # config.api_key = Rails.application.credentials.dig :anthropic, :api_key
28
+
29
+ # A file of prose about this app's own data: what it is, and where a table's rows really
30
+ # are. Everything the schema cannot say, and Claude is given it verbatim.
31
+ # config.notes = Rails.root.join 'config/omen_notes.md'
32
+
33
+ # Where Rails keeps the schema this app dumps, which is what Claude is shown.
34
+ # config.schema_path = 'db/schema.rb'
35
+ end
@@ -0,0 +1,46 @@
1
+ module Omen
2
+ # Every fact about the app this feature is installed in, so none of its own classes names one.
3
+ # All of them have a default, which is what makes the initializer a host writes optional.
4
+ class Config
5
+ # The Rails connection role a statement is read through. A host has to declare a read-only
6
+ # one: Omen raises rather than falling back to the writing role, which is the point.
7
+ attr_accessor :reading_role
8
+
9
+ # The Postgres role a statement runs as, narrower again than the connection's own.
10
+ attr_accessor :narrow_role
11
+
12
+ # The Claude model a question is asked of.
13
+ attr_accessor :claude_model
14
+
15
+ # How many rows of one answer a page will show.
16
+ attr_accessor :maximum_rows
17
+
18
+ # The key the Anthropic API is reached with. Left unset, the SDK resolves one of its own.
19
+ attr_accessor :api_key
20
+
21
+ # What a host states as a name or as a path, each of them resolved only when it is wanted.
22
+ attr_writer :record_class, :notes, :schema_path
23
+
24
+ def initialize
25
+ @reading_role = :reading
26
+ @narrow_role = 'omen_inquirer'
27
+ @claude_model = 'claude-opus-5'
28
+ @maximum_rows = 100
29
+ @schema_path = 'db/schema.rb'
30
+ end
31
+
32
+ # A name rather than the class, since nothing is autoloaded while an initializer runs.
33
+ # @return [Class] the Active Record whose descendants and connection roles this feature uses.
34
+ def record = @record_class ? @record_class.constantize : default_record
35
+
36
+ # @return [String] the host's own notes about its data, which its schema cannot state.
37
+ def notes = @notes ? File.read(@notes) : ''
38
+
39
+ # @return [Pathname] the file Rails keeps in step with the database, which Claude is shown.
40
+ def schema = Rails.root.join @schema_path
41
+
42
+ private
43
+
44
+ def default_record = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
45
+ end
46
+ end
@@ -0,0 +1,40 @@
1
+ module Omen
2
+ # The database function a reading's SQL reads every timestamp through. Created by the rake
3
+ # task rather than by a migration, because Rails' :ruby schema format dumps no functions, so
4
+ # db:schema:load would drop one a migration had made -- and the format cannot become :sql,
5
+ # since db/schema.rb is what Claude is shown.
6
+ module Eastern
7
+ # The zone the company works in, as Postgres names one: Time.zone.name is not one it takes.
8
+ ZONE = 'America/New_York'
9
+
10
+ # DDL, which Active Record has no expression for, and not a query.
11
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
12
+ # @return [Array<String>] the statements to run, in order.
13
+ def self.statements(connection)
14
+ [ stored(connection), instant(connection) ]
15
+ end
16
+
17
+ # A stored timestamp says nothing about its own zone, so it is named UTC and then rendered.
18
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
19
+ # @return [String] the statement declaring the function over a naked timestamp.
20
+ def self.stored(connection)
21
+ "CREATE OR REPLACE FUNCTION #{name connection}(ts timestamp) RETURNS timestamp AS " \
22
+ "$$ SELECT ts AT TIME ZONE 'UTC' AT TIME ZONE #{connection.quote ZONE} $$ " \
23
+ 'LANGUAGE sql IMMUTABLE'
24
+ end
25
+
26
+ # One conversion and not two: an instant already knows which moment it is, so naming it UTC
27
+ # first would convert it twice and answer hours out. Overloaded because Postgres will not
28
+ # cast an instant to a timestamp to resolve a call, so now() reaches neither without it.
29
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
30
+ # @return [String] the statement declaring the function over an instant.
31
+ def self.instant(connection)
32
+ "CREATE OR REPLACE FUNCTION #{name connection}(ts timestamptz) RETURNS timestamp AS " \
33
+ "$$ SELECT ts AT TIME ZONE #{connection.quote ZONE} $$ LANGUAGE sql IMMUTABLE"
34
+ end
35
+
36
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
37
+ # @return [String] the function's name, quoted.
38
+ def self.name(connection) = connection.quote_table_name Omen::Instructions::EASTERN
39
+ end
40
+ end
data/lib/omen/engine.rb CHANGED
@@ -2,5 +2,15 @@ module Omen
2
2
  # Teaches Rails where this gem's models live, and prefixes their tables with `omen_`.
3
3
  class Engine < ::Rails::Engine
4
4
  isolate_namespace Omen
5
+
6
+ # Read off what the app declares rather than by connecting, and late enough that its own
7
+ # initializer has already said where its schema is. The declared format rather than the
8
+ # applied one, so nothing here depends on which after_initialize hook ran first.
9
+ config.after_initialize do
10
+ configured = ActiveRecord::Base.configurations.configs_for env_name: Rails.env,
11
+ name: 'primary'
12
+ Omen::Requirements.met adapter: configured&.adapter, schema: Omen.config.schema,
13
+ schema_format: Rails.application.config.active_record.schema_format
14
+ end
5
15
  end
6
16
  end
@@ -0,0 +1,91 @@
1
+ module Omen
2
+ # The Postgres role a reading's own SELECT runs as, blind to the tables a reading is kept in.
3
+ # It needs no database.yml entry of its own: NOLOGIN, it is a privilege container reached
4
+ # with SET LOCAL ROLE and never connected as.
5
+ module Inquirer
6
+ # What this role may never be: a superuser bypasses GRANT outright.
7
+ ATTRIBUTES = 'NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION'
8
+
9
+ # Whom to ask, since the role a host reads through is one this gem has no name for.
10
+ WHOEVER = 'SELECT current_user'
11
+
12
+ # Said on the way through installation step one, where a host has yet to declare the role.
13
+ UNGRANTED = 'No %{role} connection is configured, so nothing was granted to whatever ' \
14
+ 'reads through it. Run this again once config/database.yml names one.'
15
+
16
+ # Creates the role and the function, on every database this environment prepares.
17
+ # @return [void]
18
+ def self.grant
19
+ environments.each do |environment|
20
+ config = ActiveRecord::Base.configurations.configs_for env_name: environment,
21
+ name: 'primary'
22
+ next unless config
23
+ ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection config do |connection|
24
+ grant_on connection
25
+ end
26
+ end
27
+ end
28
+
29
+ # @return [Array<String>] the environments whose databases this run should cover.
30
+ def self.environments = Rails.env.development? ? %w[ development test ] : [Rails.env.to_s]
31
+
32
+ # Warns rather than raises: a managed database never grants CREATEROLE, and a deploy that
33
+ # cannot make the role must still finish, having said what has to be made by hand.
34
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
35
+ # @return [void]
36
+ def self.grant_on(connection)
37
+ read_by = reader
38
+ warn UNGRANTED % { role: Omen.config.reading_role } unless read_by
39
+ members = [ read_by, connection.select_value(WHOEVER) ].compact
40
+ statements(connection, members).each { |statement| connection.execute statement }
41
+ puts "Granted SELECT on #{connection.current_database} to #{Omen.config.narrow_role}"
42
+ rescue ActiveRecord::StatementInvalid => error
43
+ warn "Could not make #{Omen.config.narrow_role}, so every reading will say this app is " \
44
+ 'misconfigured. Ask for that role, NOLOGIN, granted SELECT on every table but ' \
45
+ "#{Omen.tables.to_sentence}: #{error.message}"
46
+ end
47
+
48
+ # Discovered rather than named: SET LOCAL ROLE needs the connecting role to be a member of
49
+ # this one, and the role a host's reading connection logs in as is the host's own business.
50
+ # @return [String, nil] the Postgres user a reading is read through, where there is one.
51
+ def self.reader
52
+ Omen.config.record.connected_to role: Omen.config.reading_role do
53
+ Omen.config.record.with_connection { |connection| connection.select_value WHOEVER }
54
+ end
55
+ rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished
56
+ nil
57
+ end
58
+
59
+ # DDL, which Active Record has no expression for, and not a query.
60
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
61
+ # @param members [Array<String>] the roles that may SET LOCAL ROLE to this one. The owner
62
+ # running the task is one of them, and matters in tests, where Rails swaps the reading
63
+ # pool for the writing one and the test connection is the owner.
64
+ # @return [Array<String>] the statements to run, in order.
65
+ def self.statements(connection, members)
66
+ role = connection.quote_table_name Omen.config.narrow_role
67
+ [
68
+ 'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ' \
69
+ "#{connection.quote Omen.config.narrow_role}) THEN CREATE ROLE #{role} NOLOGIN; " \
70
+ 'END IF; END $$',
71
+ "ALTER ROLE #{role} WITH NOLOGIN #{ATTRIBUTES}",
72
+ "GRANT USAGE ON SCHEMA public TO #{role}",
73
+ "GRANT SELECT ON ALL TABLES IN SCHEMA public TO #{role}",
74
+ "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO #{role}",
75
+ *members.map { |member| "GRANT #{role} TO #{connection.quote_table_name member}" },
76
+ *revoked(connection, role),
77
+ *Eastern.statements(connection),
78
+ ]
79
+ end
80
+
81
+ # Intersected, so a bare db:create with no table yet to revoke on is not a failure.
82
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
83
+ # @param role [String] the role to hide this feature's own tables from.
84
+ # @return [Array<String>] one REVOKE per table there is.
85
+ def self.revoked(connection, role)
86
+ (connection.tables & Omen.tables).map do |table|
87
+ "REVOKE SELECT ON #{connection.quote_table_name table} FROM #{role}"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,37 @@
1
+ module Omen
2
+ # What an app has to be for this gem to keep its promises, checked as that app boots. Both
3
+ # of these rule an app out rather than inconveniencing it, so neither is worth deferring.
4
+ module Requirements
5
+ # Raised where an app cannot be one this gem works in, however else it is configured.
6
+ class Unmet < StandardError; end
7
+
8
+ # The one adapter that reports the table a result column came from, and scopes a role to
9
+ # a transaction. Everything else Omen promises rests on one of those two.
10
+ ADAPTER = 'postgresql'
11
+
12
+ # The one schema format that can be read as a prompt.
13
+ FORMAT = :ruby
14
+
15
+ # Said where the app reaches its database through anything else.
16
+ WRONG_ADAPTER = 'Omen needs a postgresql database, and this app names the adapter'
17
+
18
+ # Said where the app dumps its schema as SQL, which cannot be shown to Claude.
19
+ WRONG_FORMAT = 'Omen shows Claude db/schema.rb, so config.active_record.schema_format ' \
20
+ 'has to be :ruby, and this app sets'
21
+
22
+ # Said where the file is not there at all: a run would fail one question at a time, and
23
+ # a structure.sql copied to that path would leave the reading tables in the prompt.
24
+ NO_SCHEMA = 'Omen shows Claude the schema Rails dumps, and there is no file at'
25
+
26
+ # @param adapter [String, nil] what this app reaches its own database through.
27
+ # @param schema_format [Symbol, nil] the format it declares, nil where it declares none.
28
+ # @param schema [Pathname] where the dump is kept.
29
+ # @return [void]
30
+ def self.met(adapter:, schema_format:, schema:)
31
+ dumps = schema_format || FORMAT
32
+ raise Unmet, "#{WRONG_ADAPTER} #{adapter.inspect}" unless adapter == ADAPTER
33
+ raise Unmet, "#{WRONG_FORMAT} #{dumps.inspect}" unless dumps == FORMAT
34
+ raise Unmet, "#{NO_SCHEMA} #{schema}" unless File.exist? schema
35
+ end
36
+ end
37
+ end
data/lib/omen/stubs.rb ADDED
@@ -0,0 +1,41 @@
1
+ # Answers the Anthropic API with canned turns, so no test of a host reaches the network.
2
+ module Omen::Stubs extend ActiveSupport::Concern
3
+ # Where a message is asked for.
4
+ MESSAGES_URL = 'https://api.anthropic.com/v1/messages'
5
+
6
+ # Answers each request with the next turn, so a question and its refinement are scripted apart.
7
+ # @param turns [Array<Hash>] what Claude says, in order.
8
+ def stub_claude(*turns)
9
+ stub_request(:post, MESSAGES_URL).
10
+ to_return(*turns.map { |turn| { body: turn.to_json, headers: json_headers } })
11
+ end
12
+
13
+ # Carries a stray `caller`, so a test notices if a reply is ever replayed whole.
14
+ # @param sql [String] the statement Claude answers with, or '' to ask something instead.
15
+ # @param note [String] what Claude says about it.
16
+ # @param combine [Array<Hash>] the columns it asks to be drawn as their parts joined.
17
+ # @return [Hash] a turn where Claude answers in the shape the output schema demands.
18
+ def claude_answers(sql: '', note: '', combine: [])
19
+ answered = { sql: sql, note: note, combine: combine }
20
+ claude_turn 'end_turn', [ { type: 'text', text: answered.to_json,
21
+ caller: { type: 'assistant' }, } ]
22
+ end
23
+
24
+ # A reply cut short at max_tokens is not JSON, whatever the output schema demanded.
25
+ # @param text [String] what Claude got as far as saying.
26
+ # @return [Hash] a turn the API stopped mid-sentence.
27
+ def claude_says(text)
28
+ claude_turn 'max_tokens', [ { type: 'text', text: text } ]
29
+ end
30
+
31
+ private
32
+
33
+ def claude_turn(stop_reason, content)
34
+ { id: 'msg_01', type: 'message', role: 'assistant', model: 'claude-opus-5',
35
+ content: content, stop_reason: stop_reason,
36
+ usage: { input_tokens: 10, output_tokens: 5 },
37
+ }
38
+ end
39
+
40
+ def json_headers = { 'Content-Type' => 'application/json' }
41
+ end
data/lib/omen/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Omen
2
2
  # The version of this gem, as RubyGems knows it.
3
- VERSION = '0.1.0'
3
+ VERSION = '0.2.1'
4
4
  end
data/lib/omen.rb CHANGED
@@ -1,6 +1,23 @@
1
+ require 'active_job/performs'
2
+ require 'anthropic'
3
+
4
+ require 'omen/config'
5
+ require 'omen/eastern'
6
+ require 'omen/inquirer'
7
+ require 'omen/requirements'
1
8
  require 'omen/version'
2
9
  require 'omen/engine'
3
10
 
4
11
  # Staff ask Claude a question about the data an app holds; Claude writes the SQL, Rails runs it.
5
12
  module Omen
13
+ # Hidden twice over: out of the prompt, and out of what the statement's own role may read.
14
+ # @return [Array<String>] the tables this feature keeps its log of questions and answers in.
15
+ def self.tables = [ Omen::Reading, Omen::Question, Omen::Answer ].map(&:table_name)
16
+
17
+ # @return [Omen::Config] everything this feature has to be told about the app around it.
18
+ def self.config = @config ||= Omen::Config.new
19
+
20
+ # Yields the configuration, so a host states its own facts in one initializer.
21
+ # @return [void]
22
+ def self.configure = yield config
6
23
  end
@@ -0,0 +1,9 @@
1
+ namespace :db do
2
+ namespace :omen do
3
+ desc 'Create the role a reading runs its statement as, and the function it reads a ' \
4
+ 'timestamp through'
5
+ task grant: :environment do
6
+ Omen::Inquirer.grant
7
+ end
8
+ end
9
+ end