omen 0.4.0 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/INSTRUCTIONS.md +227 -0
- data/README.md +86 -128
- data/app/models/omen/conversation.rb +5 -3
- data/app/models/omen/executed.rb +8 -3
- data/app/models/omen/instructions.md +2 -2
- data/app/models/omen/instructions.rb +15 -24
- data/app/models/omen/narrowed.rb +35 -0
- data/app/models/omen/query.rb +6 -2
- data/app/models/omen/reading.rb +4 -1
- data/app/models/omen/refusal.rb +43 -0
- data/app/models/omen/reply.rb +20 -0
- data/app/models/omen/role.rb +14 -2
- data/app/models/omen/schema.rb +25 -4
- data/db/migrate/20260912000000_add_type_to_omen_readings.rb +6 -0
- data/lib/generators/omen/install/install_generator.rb +1 -1
- data/lib/generators/omen/install/templates/omen.rb +4 -0
- data/lib/generators/omen/pages/USAGE +25 -0
- data/lib/generators/omen/pages/pages_generator.rb +70 -0
- data/lib/generators/omen/pages/templates/_form.html.erb.tt +8 -0
- data/lib/generators/omen/pages/templates/controller.rb.tt +54 -0
- data/lib/generators/omen/pages/templates/index.html.erb.tt +12 -0
- data/lib/generators/omen/pages/templates/model.rb.tt +7 -0
- data/lib/generators/omen/pages/templates/show.html.erb.tt +45 -0
- data/lib/omen/bound.rb +26 -0
- data/lib/omen/config.rb +4 -0
- data/lib/omen/grants.rb +56 -5
- data/lib/omen/inquirer.rb +36 -20
- data/lib/omen/narrowing.rb +87 -0
- data/lib/omen/version.rb +1 -1
- data/lib/omen.rb +17 -0
- data/lib/tasks/omen.rake +16 -2
- metadata +15 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Everything Claude is told before a question: the prose, the schema
|
|
1
|
+
# Everything Claude is told before a question: the prose, and the schema it may write against.
|
|
2
2
|
class Omen::Instructions
|
|
3
3
|
# The prose, kept beside this class so it reads as prose rather than as a string.
|
|
4
4
|
PROSE = File.expand_path 'instructions.md', __dir__
|
|
@@ -13,28 +13,16 @@ class Omen::Instructions
|
|
|
13
13
|
# The database function a distance in miles is measured with, created by the same task.
|
|
14
14
|
MILES = 'omen_miles_between'
|
|
15
15
|
|
|
16
|
-
# The one shape a reply may take: both keys required, and no others admitted.
|
|
17
|
-
ANSWER = {
|
|
18
|
-
type: 'object', additionalProperties: false, required: %w[ sql note combine ],
|
|
19
|
-
properties: {
|
|
20
|
-
sql: { type: 'string',
|
|
21
|
-
description: 'The one PostgreSQL SELECT that answers the question, or ' \
|
|
22
|
-
'empty to ask something first.', },
|
|
23
|
-
note: { type: 'string',
|
|
24
|
-
description: 'A sentence or two: what the query returns and any ' \
|
|
25
|
-
'assumption made. If sql is empty, the question you ' \
|
|
26
|
-
'need answered first.', },
|
|
27
|
-
combine: Omen::Combination::SCHEMA,
|
|
28
|
-
},
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
# @return [Hash] what a reply is constrained to, so that it always parses.
|
|
32
|
-
def self.output_config = { format_: { type: :json_schema, schema: ANSWER } }
|
|
33
|
-
|
|
34
16
|
# Cached for an hour: the schema is thousands of tokens, and refining sends it again.
|
|
17
|
+
# @param reading [Omen::Reading] the one being answered, which says what it may read.
|
|
35
18
|
# @return [Array<Hash>] the one system block of a request.
|
|
36
|
-
def self.block
|
|
37
|
-
[ { type: 'text', text: new.text, cache_control: { type: 'ephemeral', ttl: '1h' } } ]
|
|
19
|
+
def self.block(reading)
|
|
20
|
+
[ { type: 'text', text: new(reading).text, cache_control: { type: 'ephemeral', ttl: '1h' } } ]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param reading [Omen::Reading] the one being answered, which says what it may read.
|
|
24
|
+
def initialize(reading)
|
|
25
|
+
@reading = reading
|
|
38
26
|
end
|
|
39
27
|
|
|
40
28
|
# Today's date is said out loud because "last month" is Claude's to resolve, and it has no clock.
|
|
@@ -43,17 +31,20 @@ class Omen::Instructions
|
|
|
43
31
|
format File.read(PROSE), today: Date.current.to_fs(:long), zone_fn: TIME_ZONE,
|
|
44
32
|
today_fn: TODAY, miles_fn: MILES,
|
|
45
33
|
schema: schema, types: types, readable: readable, refused: refused,
|
|
46
|
-
notes:
|
|
34
|
+
notes: @reading.notes
|
|
47
35
|
end
|
|
48
36
|
|
|
49
37
|
private
|
|
50
38
|
|
|
51
|
-
def schema = Omen::Schema.new.text
|
|
39
|
+
def schema = Omen::Schema.new(@reading.runs_as).text
|
|
52
40
|
|
|
41
|
+
# This gem's own tables are told apart by a type column too, and are hidden from the schema,
|
|
42
|
+
# so naming their subclasses here would say the one thing the schema is careful not to.
|
|
53
43
|
def types
|
|
54
44
|
Rails.application.eager_load!
|
|
55
45
|
Omen.config.record.descendants.select(&:finder_needs_type_condition?)
|
|
56
|
-
.group_by(&:table_name).
|
|
46
|
+
.group_by(&:table_name).except(*Omen.tables).sort
|
|
47
|
+
.map { |table, kinds| "- `#{table}`: #{named kinds}" }.join "\n"
|
|
57
48
|
end
|
|
58
49
|
|
|
59
50
|
def named(kinds) = kinds.map(&:name).sort.map { |name| "`#{name}`" }.join ', '
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Extends Omen::Reading with the audience it is asked by: what that audience may read, and the
|
|
2
|
+
# Postgres role that holds it there.
|
|
3
|
+
module Omen::Narrowed extend ActiveSupport::Concern
|
|
4
|
+
included do
|
|
5
|
+
# What this kind of reading is held to, where a host declared one.
|
|
6
|
+
class_attribute :narrowing
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class_methods do
|
|
10
|
+
# Declares what a reading of this kind may read, which db:omen:grant then writes.
|
|
11
|
+
# @param role [String] the Postgres role its statement runs as.
|
|
12
|
+
# @param by [Symbol] the column of the reading that says whose rows these are.
|
|
13
|
+
# @param own [Hash] every table it reads rows of, to what makes a row its own -- written
|
|
14
|
+
# with `%{owner}`, which stands for whoever the reading names.
|
|
15
|
+
# @param whole [Array<String>] the tables it reads whole, nobody's in particular.
|
|
16
|
+
# @param except [Regexp, nil] the columns of those it may not read.
|
|
17
|
+
# @return [void]
|
|
18
|
+
def narrows(role, by:, own:, whole: [], except: nil)
|
|
19
|
+
self.narrowing = Omen::Narrowing.new role: role, by: by, own: own, whole: whole,
|
|
20
|
+
except: except
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array<Omen::Narrowing>] every one a host has declared, over every kind of reading.
|
|
24
|
+
def narrowings
|
|
25
|
+
Rails.application.eager_load!
|
|
26
|
+
descendants.filter_map(&:narrowing).uniq
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [String] Postgres role the statement runs as, which holds what it may read.
|
|
31
|
+
def runs_as = narrowing&.role || Omen.config.narrow_role
|
|
32
|
+
|
|
33
|
+
# @return [Hash] what is set before the statement, for a row policy of the host's to read.
|
|
34
|
+
def settings = narrowing ? { narrowing.setting => self[narrowing.by] } : {}
|
|
35
|
+
end
|
data/app/models/omen/query.rb
CHANGED
|
@@ -4,8 +4,10 @@ class Omen::Query
|
|
|
4
4
|
UTC = { oid: 1114, name: 'timestamp', format: 0 }
|
|
5
5
|
|
|
6
6
|
# @param sql [String] the statement Claude answered with.
|
|
7
|
-
|
|
7
|
+
# @param reading [Omen::Reading] the one it answers, which says what it may read.
|
|
8
|
+
def initialize(sql, reading)
|
|
8
9
|
@sql = sql
|
|
10
|
+
@reading = reading
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
# A savepoint, so a statement Postgres rejects leaves the surrounding transaction usable.
|
|
@@ -23,10 +25,12 @@ private
|
|
|
23
25
|
def record = Omen.config.record
|
|
24
26
|
|
|
25
27
|
def run(connection)
|
|
26
|
-
answered =
|
|
28
|
+
answered = role(connection).around { executed connection }
|
|
27
29
|
{ result: answered.to_a.first(cap), provenance: Omen::Column.of(answered, connection) }
|
|
28
30
|
end
|
|
29
31
|
|
|
32
|
+
def role(connection) = Omen::Role.new connection, @reading.runs_as, @reading.settings
|
|
33
|
+
|
|
30
34
|
def executed(connection)
|
|
31
35
|
raw = connection.raw_connection
|
|
32
36
|
result = raw.exec_params capped, []
|
data/app/models/omen/reading.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# A thread of questions somebody asks Claude about the data this app holds.
|
|
2
2
|
class Omen::Reading < Omen.config.record
|
|
3
|
-
include Omen::Asked, Omen::Executed, Omen::Stated
|
|
3
|
+
include Omen::Asked, Omen::Executed, Omen::Narrowed, Omen::Stated
|
|
4
4
|
|
|
5
5
|
has_many :questions, -> { order :id }, dependent: :destroy
|
|
6
6
|
|
|
@@ -15,6 +15,9 @@ class Omen::Reading < Omen.config.record
|
|
|
15
15
|
|
|
16
16
|
# @return [String] the default representation (used in views).
|
|
17
17
|
def to_s = questions.first&.text.to_s.truncate 80
|
|
18
|
+
|
|
19
|
+
# @return [String] the host's notes about its data, as this reading's asker is told them.
|
|
20
|
+
def notes = Omen.config.notes
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
ActiveSupport.run_load_hooks :omen_reading, Omen::Reading
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# What a Postgres role may not read: the tables it holds no SELECT on, and the columns of the
|
|
2
|
+
# rest that a column-level grant left out. Each is asked of a role the database has to have, so
|
|
3
|
+
# a name it does not know refuses nothing here -- the statement is what says the app is
|
|
4
|
+
# misconfigured, in the one message that case is owed.
|
|
5
|
+
class Omen::Refusal
|
|
6
|
+
# Every column of the app the role is refused. Asked of the column rather than of the table,
|
|
7
|
+
# since a grant naming columns leaves the table itself ungranted and every one of these true.
|
|
8
|
+
COLUMNS = <<~SQL
|
|
9
|
+
SELECT c.relname, a.attname FROM pg_roles r, pg_class c
|
|
10
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
11
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
12
|
+
WHERE r.rolname = ? AND n.nspname = 'public' AND c.relkind = 'r'
|
|
13
|
+
AND NOT has_column_privilege(r.oid, c.oid, a.attnum, 'SELECT')
|
|
14
|
+
SQL
|
|
15
|
+
|
|
16
|
+
# Every table of it none of whose columns the role may read, this gem's own included.
|
|
17
|
+
TABLES = <<~SQL
|
|
18
|
+
SELECT c.relname FROM pg_roles r, pg_class c
|
|
19
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
20
|
+
WHERE r.rolname = ? AND n.nspname = 'public' AND c.relkind = 'r'
|
|
21
|
+
AND NOT EXISTS (SELECT 1 FROM pg_attribute a
|
|
22
|
+
WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
23
|
+
AND has_column_privilege(r.oid, c.oid, a.attnum, 'SELECT'))
|
|
24
|
+
SQL
|
|
25
|
+
|
|
26
|
+
# @param role [String] the Postgres role a statement will run as.
|
|
27
|
+
def initialize(role)
|
|
28
|
+
@role = role
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Array<String>] the tables it may not read.
|
|
32
|
+
def tables = Omen.tables | asked(TABLES).flatten
|
|
33
|
+
|
|
34
|
+
# @return [Array<Array<String>>] the table and column of each one it may not read.
|
|
35
|
+
def columns = asked COLUMNS
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def asked(sql)
|
|
40
|
+
named = Omen.config.record.sanitize_sql_array [ sql, @role ]
|
|
41
|
+
Omen.config.record.with_connection { |connection| connection.select_rows named }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# The one shape a reply may take, which is what makes every reply parse.
|
|
2
|
+
class Omen::Reply
|
|
3
|
+
# Both keys required, and no others admitted.
|
|
4
|
+
SHAPE = {
|
|
5
|
+
type: 'object', additionalProperties: false, required: %w[ sql note combine ],
|
|
6
|
+
properties: {
|
|
7
|
+
sql: { type: 'string',
|
|
8
|
+
description: 'The one PostgreSQL SELECT that answers the question, or ' \
|
|
9
|
+
'empty to ask something first.', },
|
|
10
|
+
note: { type: 'string',
|
|
11
|
+
description: 'A sentence or two: what the query returns and any ' \
|
|
12
|
+
'assumption made. If sql is empty, the question you ' \
|
|
13
|
+
'need answered first.', },
|
|
14
|
+
combine: Omen::Combination::SCHEMA,
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# @return [Hash] what a reply is constrained to, so that it always parses.
|
|
19
|
+
def self.output_config = { format_: { type: :json_schema, schema: SHAPE } }
|
|
20
|
+
end
|
data/app/models/omen/role.rb
CHANGED
|
@@ -8,8 +8,12 @@ class Omen::Role
|
|
|
8
8
|
'the read-only role a statement runs as.'
|
|
9
9
|
|
|
10
10
|
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] the one to switch.
|
|
11
|
-
|
|
11
|
+
# @param name [String] the role to enter.
|
|
12
|
+
# @param settings [Hash] what to set first, which a row policy may read to scope the rows.
|
|
13
|
+
def initialize(connection, name, settings = {})
|
|
12
14
|
@connection = connection
|
|
15
|
+
@name = name
|
|
16
|
+
@settings = settings
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
# Given up before anything of ours runs; a statement Postgres refused rolls it back instead.
|
|
@@ -21,9 +25,17 @@ class Omen::Role
|
|
|
21
25
|
|
|
22
26
|
private
|
|
23
27
|
|
|
28
|
+
# Set before the role rather than after it, so a setting is written by the role that holds
|
|
29
|
+
# the rows rather than by the one that is about to be refused them.
|
|
24
30
|
def enter
|
|
25
|
-
@
|
|
31
|
+
@settings.each { |name, value| set name, value }
|
|
32
|
+
@connection.execute "SET LOCAL ROLE #{@connection.quote_table_name @name}"
|
|
26
33
|
rescue ActiveRecord::StatementInvalid
|
|
27
34
|
raise Unavailable, MISCONFIGURED
|
|
28
35
|
end
|
|
36
|
+
|
|
37
|
+
# Through set_config, since a name carrying a dot is not one SET LOCAL can be given quoted.
|
|
38
|
+
def set(name, value)
|
|
39
|
+
@connection.raw_connection.exec_params 'SELECT set_config($1, $2, true)', [ name, value.to_s ]
|
|
40
|
+
end
|
|
29
41
|
end
|
data/app/models/omen/schema.rb
CHANGED
|
@@ -1,22 +1,43 @@
|
|
|
1
|
-
# What Claude writes its SQL against: the app's own schema, less the
|
|
1
|
+
# What Claude writes its SQL against: the app's own schema, less what the role may not read.
|
|
2
2
|
class Omen::Schema
|
|
3
3
|
# A plain index is a note about speed; a unique one is a fact about the rows, so it stays.
|
|
4
4
|
HINT = /^\s*t\.index (?!.*unique: true).*\n/
|
|
5
5
|
|
|
6
|
-
# @
|
|
7
|
-
def
|
|
6
|
+
# @param role [String] the Postgres role the statement will run as.
|
|
7
|
+
def initialize(role)
|
|
8
|
+
@role = role
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# @return [String] the schema, without a table or a column the role is refused, and without
|
|
12
|
+
# an enum or a foreign key left behind by one.
|
|
13
|
+
def text = without_orphan_enums without_columns without_tables source
|
|
8
14
|
|
|
9
15
|
private
|
|
10
16
|
|
|
11
17
|
def source = File.read(Omen.config.schema).gsub HINT, ''
|
|
12
18
|
|
|
13
|
-
def
|
|
19
|
+
def refusal = Omen::Refusal.new @role
|
|
20
|
+
|
|
21
|
+
def without_tables(text)
|
|
22
|
+
refusal.tables.inject(text) { |left, name| without_table left, name }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def without_columns(text)
|
|
26
|
+
refusal.columns.inject(text) { |left, (table, column)| without_column left, table, column }
|
|
27
|
+
end
|
|
14
28
|
|
|
15
29
|
def without_table(text, name)
|
|
16
30
|
text.gsub(/^ create_table "#{name}".*?\n end\n\n?/m, '')
|
|
17
31
|
.gsub(/^ add_foreign_key ("#{name}"|"\w+", "#{name}").*\n/, '')
|
|
18
32
|
end
|
|
19
33
|
|
|
34
|
+
def without_column(text, table, column)
|
|
35
|
+
text.sub(/^ create_table "#{table}".*?\n end\n/m) do |block|
|
|
36
|
+
block.gsub(/^ t\.\w+ "#{column}".*\n/, '')
|
|
37
|
+
.gsub(/^ t\.index \[[^\]]*"#{column}".*\n/, '')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
20
41
|
def without_orphan_enums(text)
|
|
21
42
|
# Derived rather than named, so a type the cut tables shared with another survives
|
|
22
43
|
text.gsub(/^ create_enum "(\w+)".*\n/) do |line|
|
|
@@ -2,7 +2,7 @@ require 'rails/generators/active_record'
|
|
|
2
2
|
|
|
3
3
|
module Omen
|
|
4
4
|
module Generators
|
|
5
|
-
# Everything installing this gem writes into a host:
|
|
5
|
+
# Everything installing this gem writes into a host: its migrations and one initializer.
|
|
6
6
|
# Not the roles or the database function, which are a rake task, because a copy of those
|
|
7
7
|
# would drift from what the gem goes on to expect and nothing would detect it.
|
|
8
8
|
class InstallGenerator < Rails::Generators::Base
|
|
@@ -23,6 +23,10 @@ Omen.configure do |config|
|
|
|
23
23
|
# there are more without counting them.
|
|
24
24
|
# config.maximum_rows = 100
|
|
25
25
|
|
|
26
|
+
# How many turns of a thread travel with the next question. A thread nobody clears would
|
|
27
|
+
# otherwise carry its whole history into each one, and be charged for it again each time.
|
|
28
|
+
# config.remembered = 20
|
|
29
|
+
|
|
26
30
|
# Left unset, the Anthropic SDK resolves ANTHROPIC_API_KEY and its own wider chain.
|
|
27
31
|
# config.api_key = Rails.application.credentials.dig :anthropic, :api_key
|
|
28
32
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Writes the pages a reading is seen through, into this app: a name for one, a controller,
|
|
3
|
+
three views and a route. All of it is this app's from the moment it lands -- rename it,
|
|
4
|
+
restyle it, throw it away.
|
|
5
|
+
|
|
6
|
+
The controller descends from ApplicationController, so whatever guards the rest of this
|
|
7
|
+
app guards the one page that will run SQL across all of it.
|
|
8
|
+
|
|
9
|
+
Where Turbo is installed, the show page subscribes to the reading and an initializer
|
|
10
|
+
declaring broadcasts_refreshes is written beside it, so an answer arrives on its own
|
|
11
|
+
rather than waiting for a reload.
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
bin/rails generate omen:pages
|
|
15
|
+
|
|
16
|
+
creates app/models/inquiry.rb
|
|
17
|
+
app/controllers/inquiries_controller.rb
|
|
18
|
+
app/views/inquiries/index.html.erb
|
|
19
|
+
app/views/inquiries/show.html.erb
|
|
20
|
+
app/views/inquiries/_form.html.erb
|
|
21
|
+
route resources :inquiries
|
|
22
|
+
|
|
23
|
+
bin/rails generate omen:pages Consultation
|
|
24
|
+
|
|
25
|
+
the same, under a name of this app's own choosing.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require 'rails/generators/named_base'
|
|
2
|
+
|
|
3
|
+
module Omen
|
|
4
|
+
module Generators
|
|
5
|
+
# The least a host needs to see a reading at all: a name for one, a controller, three views
|
|
6
|
+
# and a route. All of it lands in the host and is the host's from then on -- Omen has no
|
|
7
|
+
# opinion about what a reading looks like. It has one about what it takes to draw one, and
|
|
8
|
+
# that is the part nobody should have to learn before their first question.
|
|
9
|
+
class PagesGenerator < Rails::Generators::NamedBase
|
|
10
|
+
source_root File.expand_path('templates', __dir__)
|
|
11
|
+
|
|
12
|
+
# One per action that renders, and the form the other two share.
|
|
13
|
+
VIEWS = %w[ index show _form ]
|
|
14
|
+
|
|
15
|
+
# Its own file rather than a line in the initializer, so that deleting these pages deletes
|
|
16
|
+
# everything they asked for. Declared on Omen::Reading and not on the name below it,
|
|
17
|
+
# because a question reaches its reading through the association: what a job is handed,
|
|
18
|
+
# and so what it broadcasts as, is always the gem's own class.
|
|
19
|
+
BROADCASTS = <<~RUBY
|
|
20
|
+
# Refreshes every page showing a reading when its answer lands, which is long after the
|
|
21
|
+
# response that asked for it went out. Written by `rails g omen:pages`, and safe to
|
|
22
|
+
# delete along with the pages it was written for.
|
|
23
|
+
ActiveSupport.on_load(:omen_reading) { broadcasts_refreshes }
|
|
24
|
+
RUBY
|
|
25
|
+
|
|
26
|
+
remove_argument :name
|
|
27
|
+
argument :name, type: :string, default: 'Inquiry',
|
|
28
|
+
desc: 'What this app calls a reading of its own'
|
|
29
|
+
|
|
30
|
+
# Raised on rather than written over: a host that already calls something Inquiry would
|
|
31
|
+
# otherwise find these pages standing where its own class used to, and quietly. The name
|
|
32
|
+
# is the argument, so `omen:pages Consultation` is the whole of the fix.
|
|
33
|
+
# @return [void]
|
|
34
|
+
def check_collisions = class_collisions class_name, "#{plural_name.camelize}Controller"
|
|
35
|
+
|
|
36
|
+
# @return [void]
|
|
37
|
+
def copy_model = template 'model.rb', "app/models/#{singular_name}.rb"
|
|
38
|
+
|
|
39
|
+
# @return [void]
|
|
40
|
+
def copy_controller
|
|
41
|
+
template 'controller.rb', "app/controllers/#{plural_name}_controller.rb"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [void]
|
|
45
|
+
def copy_views
|
|
46
|
+
VIEWS.each do |view|
|
|
47
|
+
template "#{view}.html.erb", "app/views/#{plural_name}/#{view}.html.erb"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [void]
|
|
52
|
+
def add_route = route "resources :#{plural_name}"
|
|
53
|
+
|
|
54
|
+
# @return [void]
|
|
55
|
+
def declare_broadcasts
|
|
56
|
+
return unless turbo?
|
|
57
|
+
|
|
58
|
+
create_file 'config/initializers/omen_broadcasts.rb', BROADCASTS
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Asked once, here, rather than by the pages at every render: a host either has it or has
|
|
64
|
+
# no use for the line. Without one the pages still work, they only stop arriving on their
|
|
65
|
+
# own, and a reload is what shows an answer.
|
|
66
|
+
# @return [Boolean] whether this app has a Turbo to broadcast a refresh over.
|
|
67
|
+
def turbo? = Object.const_defined? :Turbo
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%%= form_with model: <%= singular_name %> do |form| %>
|
|
2
|
+
<%% if <%= singular_name %>.errors.any? %>
|
|
3
|
+
<p style="color: red"><%%= <%= singular_name %>.errors.full_messages.to_sentence %></p>
|
|
4
|
+
<%% end %>
|
|
5
|
+
|
|
6
|
+
<div><%%= form.textarea :question, rows: 3, cols: 60 %></div>
|
|
7
|
+
<div><%%= form.submit 'Ask' %></div>
|
|
8
|
+
<%% end %>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# The pages of a reading. Written by `rails g omen:pages` and this app's from then on, which is
|
|
2
|
+
# also why it descends from ApplicationController: whatever guards the rest of this app guards
|
|
3
|
+
# the one page here that will run SQL across all of it.
|
|
4
|
+
class <%= plural_name.camelize %>Controller < ApplicationController
|
|
5
|
+
before_action :set_<%= singular_name %>, only: %i[ show update destroy ]
|
|
6
|
+
|
|
7
|
+
# GET /<%= plural_name %>
|
|
8
|
+
def index
|
|
9
|
+
@<%= plural_name %> = <%= class_name %>.order id: :desc
|
|
10
|
+
@<%= singular_name %> = <%= class_name %>.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# GET /<%= plural_name %>/1
|
|
14
|
+
def show
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# POST /<%= plural_name %>
|
|
18
|
+
# Opening a reading is asking its first question, so there is no page for a blank one.
|
|
19
|
+
def create
|
|
20
|
+
@<%= singular_name %> = <%= class_name %>.new <%= singular_name %>_params
|
|
21
|
+
|
|
22
|
+
if @<%= singular_name %>.save
|
|
23
|
+
redirect_to @<%= singular_name %>
|
|
24
|
+
else
|
|
25
|
+
@<%= plural_name %> = <%= class_name %>.order id: :desc
|
|
26
|
+
render :index, status: :unprocessable_content
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# PATCH/PUT /<%= plural_name %>/1
|
|
31
|
+
# A reading is never edited: what was asked is what it stays. Asking again adds to the thread,
|
|
32
|
+
# and Claude is shown the whole of it when it answers.
|
|
33
|
+
def update
|
|
34
|
+
question = <%= singular_name %>_params[:question]
|
|
35
|
+
@<%= singular_name %>.ask(question) if question.present?
|
|
36
|
+
|
|
37
|
+
redirect_to @<%= singular_name %>
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# DELETE /<%= plural_name %>/1
|
|
41
|
+
def destroy
|
|
42
|
+
@<%= singular_name %>.destroy!
|
|
43
|
+
|
|
44
|
+
redirect_to <%= plural_name %>_path, status: :see_other
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def set_<%= singular_name %>
|
|
50
|
+
@<%= singular_name %> = <%= class_name %>.find params.expect(:id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def <%= singular_name %>_params = params.expect <%= singular_name %>: [ :question ]
|
|
54
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<h1><%= plural_name.humanize %></h1>
|
|
2
|
+
|
|
3
|
+
<%%= render 'form', <%= singular_name %>: @<%= singular_name %> %>
|
|
4
|
+
|
|
5
|
+
<ul>
|
|
6
|
+
<%% @<%= plural_name %>.each do |<%= singular_name %>| %>
|
|
7
|
+
<li>
|
|
8
|
+
<%%= link_to <%= singular_name %>, <%= singular_name %> %>
|
|
9
|
+
<%% if <%= singular_name %>.answering? %><em>answering</em><%% end %>
|
|
10
|
+
</li>
|
|
11
|
+
<%% end %>
|
|
12
|
+
</ul>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# A reading of this app's own data: what somebody asked Claude, and what came back.
|
|
2
|
+
#
|
|
3
|
+
# Omen::Reading has a type column nowhere, so this is a second name for the same rows rather
|
|
4
|
+
# than a kind of them. <%= class_name %>.all carries no condition, and a belongs_to declared here
|
|
5
|
+
# is one that every reading has.
|
|
6
|
+
class <%= class_name %> < Omen::Reading
|
|
7
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<%- if turbo? -%>
|
|
2
|
+
<%%= turbo_stream_from @<%= singular_name %>.becomes(Omen::Reading) %>
|
|
3
|
+
|
|
4
|
+
<%- end -%>
|
|
5
|
+
<p><%%= link_to 'All <%= plural_name %>', <%= plural_name %>_path %></p>
|
|
6
|
+
|
|
7
|
+
<%% @<%= singular_name %>.questions.each do |question| %>
|
|
8
|
+
<h2><%%= question.text %></h2>
|
|
9
|
+
|
|
10
|
+
<%% if (answer = question.answer) %>
|
|
11
|
+
<p><%%= answer.note %></p>
|
|
12
|
+
|
|
13
|
+
<%% if answer.error.present? %>
|
|
14
|
+
<p style="color: red"><%%= answer.error %></p>
|
|
15
|
+
<%% elsif answer.sql.present? %>
|
|
16
|
+
<details>
|
|
17
|
+
<summary>SQL</summary>
|
|
18
|
+
<pre><%%= answer.sql %></pre>
|
|
19
|
+
</details>
|
|
20
|
+
|
|
21
|
+
<table border="1" cellpadding="4">
|
|
22
|
+
<tr><%% answer.shown.first&.each_key do |header| %><th><%%= header %></th><%% end %></tr>
|
|
23
|
+
<%% answer.shown.each do |row| %>
|
|
24
|
+
<tr><%% row.each_value do |value| %><td><%%= value %></td><%% end %></tr>
|
|
25
|
+
<%% end %>
|
|
26
|
+
</table>
|
|
27
|
+
|
|
28
|
+
<%% if answer.truncated? %>
|
|
29
|
+
<p>The first <%%= Omen.config.maximum_rows %> rows, and there are more.</p>
|
|
30
|
+
<%% end %>
|
|
31
|
+
<%% end %>
|
|
32
|
+
<%% end %>
|
|
33
|
+
<%% end %>
|
|
34
|
+
|
|
35
|
+
<%% if @<%= singular_name %>.answering? %>
|
|
36
|
+
<p>Claude is working on it.</p>
|
|
37
|
+
<%% else %>
|
|
38
|
+
<%% if @<%= singular_name %>.failed? %>
|
|
39
|
+
<p style="color: red">That one could not be answered. Asking again starts it over.</p>
|
|
40
|
+
<%% end %>
|
|
41
|
+
|
|
42
|
+
<%%= render 'form', <%= singular_name %>: @<%= singular_name %> %>
|
|
43
|
+
<%% end %>
|
|
44
|
+
|
|
45
|
+
<%%= button_to 'Delete', @<%= singular_name %>, method: :delete %>
|
data/lib/omen/bound.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Omen
|
|
2
|
+
# Which roles a restrictive policy really holds back. A role that inherits the privileges of
|
|
3
|
+
# a narrowed one is narrowed along with it, so a page can go empty for somebody nobody meant
|
|
4
|
+
# to narrow -- silently, since an empty answer raises nothing.
|
|
5
|
+
module Bound
|
|
6
|
+
# A policy is written for one role and named after it, so a binding anybody asked for is
|
|
7
|
+
# one whose policy name ends in the name of the role it turned up for.
|
|
8
|
+
ROLES = <<~SQL
|
|
9
|
+
SELECT p.tablename, p.policyname, r.rolname FROM pg_policies p
|
|
10
|
+
CROSS JOIN LATERAL unnest(p.roles) AS named(rolname)
|
|
11
|
+
JOIN pg_roles r ON pg_has_role(r.oid, named.rolname::regrole::oid, 'USAGE')
|
|
12
|
+
WHERE p.permissive = 'RESTRICTIVE' AND p.schemaname = 'public' AND NOT r.rolsuper
|
|
13
|
+
ORDER BY p.tablename, p.policyname, r.rolname
|
|
14
|
+
SQL
|
|
15
|
+
|
|
16
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] any one.
|
|
17
|
+
# @return [Array<Array<String>>] the table, the policy and the role it holds back.
|
|
18
|
+
def self.roles(connection) = connection.select_rows ROLES
|
|
19
|
+
|
|
20
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] any one.
|
|
21
|
+
# @return [Array<Array<String>>] the ones a host did not ask for.
|
|
22
|
+
def self.surprises(connection)
|
|
23
|
+
roles(connection).reject { |_table, policy, role| policy.end_with? role }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/omen/config.rb
CHANGED
|
@@ -15,6 +15,9 @@ module Omen
|
|
|
15
15
|
# How many rows of one answer a page will show.
|
|
16
16
|
attr_accessor :maximum_rows
|
|
17
17
|
|
|
18
|
+
# How many turns of a thread travel with the next question.
|
|
19
|
+
attr_accessor :remembered
|
|
20
|
+
|
|
18
21
|
# The key the Anthropic API is reached with. Left unset, the SDK resolves one of its own.
|
|
19
22
|
attr_accessor :api_key
|
|
20
23
|
|
|
@@ -26,6 +29,7 @@ module Omen
|
|
|
26
29
|
@narrow_role = 'omen_inquirer'
|
|
27
30
|
@claude_model = 'claude-opus-5'
|
|
28
31
|
@maximum_rows = 100
|
|
32
|
+
@remembered = 20
|
|
29
33
|
@schema_path = 'db/schema.rb'
|
|
30
34
|
end
|
|
31
35
|
|