omen 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5c61b5e791393a47ef018d36fba4ab6455147fd7789e9ad9da2dfa3f4a3c233
4
- data.tar.gz: 0ad45e83dfa5a5271acdd9c1a99741dcd360eb3d520f5b6ffe3b81d383e39152
3
+ metadata.gz: '0439ba57f67515df5b1685bb241e7c8d904f7039b3c349a45add84eaec5b6330'
4
+ data.tar.gz: 125790550dbc3c82b2a139c4a09fc1767ea6776ce984357aa40841e397adef38
5
5
  SHA512:
6
- metadata.gz: dbe0dcd9d8dbe1e272b5a9f4d600631c4fa3efca709d80ea71aab1c1212317b462ce516c96d0b18292e2364792bb2167de86a9fb9b9de5cd64f97d4861e55821
7
- data.tar.gz: 2fad605e52e97a599c01b842f2e00df7dc77827f1f7c7cfc22eb340101c038857e081766c90739da17c1ee3f3b469627442406c2b6c4d80984008cb0aef0847f
6
+ metadata.gz: 3123b62849d8a61bdd7264aa1eba6a13fe0bc8b5225be52cd4d5633840b5117181dca928c1ed21e6a26b54d45650f39037221cedfe8f95f7af1b5047b1523dae
7
+ data.tar.gz: 6e1a011344a1ec0cb7c09657a8bdfa40159de13f4d65f892f6a23cba5ce965959224fe8c4bd4585d68d82290773035e439c0eb4ec47bf428e6f0e2814890f251
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ For more information about changelogs, check [Keep a Changelog](http://keepachangelog.com) and
6
+ [Vandamme](http://tech-angels.github.io/vandamme).
7
+
8
+ ## [Unreleased]
9
+
10
+ * [Fix] Point RubyGems at the API reference, so a gem page and a gem listing say the same
11
+ thing about where the documentation is
12
+
13
+ ## 0.2.0 - 2026-08-21
14
+
15
+ * [Feature] Ask Claude a question about an app's own data, and run the SQL it writes against a
16
+ read-only connection: three models, the prompt, the narrow role and the function a timestamp
17
+ is read through, an install generator, and no controllers, routes or views
18
+
19
+ ## 0.1.0 - 2026-08-21
20
+
21
+ * [Feature] An engine that loads and does not do anything yet
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 HouseAccount
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md CHANGED
@@ -6,9 +6,8 @@ Omen ships the models and the logic to talk to Claude, parse what it says back,
6
6
  statement it wrote against a read-only connection. It ships **no controllers, routes or views** —
7
7
  the pages belong to the app that installs it.
8
8
 
9
- > **0.1.0 does nothing yet.** This release loads as a Rails engine. The models
10
- > and the Claude logic land in a later version. The requirements below are what it will need, and
11
- > are stated now so nobody installs it into an app it cannot work in.
9
+ The rows never travel. Claude is shown the schema and writes one `SELECT`; Rails runs it and
10
+ draws the answer for whoever asked. Nothing that statement returned is ever sent back.
12
11
 
13
12
  ## How to install
14
13
 
@@ -19,7 +18,7 @@ gem install omen
19
18
  Or, in a `Gemfile`, pinned to the current major:
20
19
 
21
20
  ```ruby
22
- gem 'omen', '~> 0.1'
21
+ gem 'omen', '~> 0.2'
23
22
  ```
24
23
 
25
24
  Omen follows [Semantic Versioning](https://semver.org), so `~> major.minor` means `bundle update`
@@ -33,14 +32,114 @@ and column number Postgres reports for each result column, which is what stops a
33
32
  expression from laundering one; and it narrows privileges for the statement it runs with
34
33
  `SET LOCAL ROLE`, which reverts when the transaction ends. MySQL has `SET ROLE` but nothing
35
34
  transaction-scoped, so a raised exception would leave a pooled connection holding the role. Omen
36
- raises on any other adapter rather than running with a weaker promise.
35
+ raises at boot on any other adapter rather than running with a weaker promise.
37
36
 
38
37
  **`db/schema.rb`, in Rails' `:ruby` schema format.** The schema is what Claude is shown, so an app
39
- on `db/structure.sql` cannot use Omen. This is checked at boot rather than left to fail later.
38
+ on `db/structure.sql` cannot use Omen. Checked at boot and raised on, because copying a
39
+ `structure.sql` to that path fails silently and with teeth: the strip regexes read the Ruby DSL,
40
+ so they match nothing, Omen's own tables stay in the prompt, and Claude is shown the log of every
41
+ question ever asked.
40
42
 
41
- **A read-only connection.** Omen runs every statement through a Rails connection role that cannot
42
- write, and raises rather than falling back to a writable one.
43
+ **Eastern time.** A stored timestamp is read through an `eastern()` function the rake task
44
+ creates, and every date the prompt asks Claude to reason about is a date in `America/New_York`.
45
+ An app that works in another zone has to say so in the function and rename it.
46
+
47
+ ## Configuration
48
+
49
+ Installing is three commands and, if you want to change anything, one file.
50
+
51
+ ### The steps
52
+
53
+ ```sh
54
+ bin/rails generate omen:install # three migrations, and an initializer you may delete
55
+ bin/rails db:migrate
56
+ bin/rails db:omen:grant # the role a statement runs as, and the function it reads a
57
+ # timestamp through
58
+ ```
59
+
60
+ `db:omen:grant` is worth running from the tasks that build a database, so a fresh one is never
61
+ missing the role. In `lib/tasks` of the host:
62
+
63
+ ```ruby
64
+ granted = Rake::Task['db:omen:grant']
65
+
66
+ %w[ db:create db:prepare db:reset db:test:prepare ].each do |name|
67
+ Rake::Task[name].enhance do
68
+ granted.reenable
69
+ granted.invoke
70
+ end
71
+ end
72
+ ```
73
+
74
+ ### What the app around it has to provide
75
+
76
+ Configuration is optional. Installation is not: four of these are the app's, and Omen cannot
77
+ supply any of them.
78
+
79
+ - **A read-only connection role.** `connects_to database: { writing: :primary, reading: :reader }`
80
+ on the record class, with the `reading` entry logging in as a Postgres role granted `SELECT`
81
+ and nothing else. Omen raises rather than falling back to a role that could write, which is
82
+ the point. Creating that role is the app's own business — Omen has no name for it, and
83
+ discovers it when granting.
84
+ - **Active Record Encryption keys.** Without them an encrypted column reads back as the
85
+ placeholder rather than as the value, quietly.
86
+ - **An `ApplicationJob`.** A reading is answered outside the request, and the job descends
87
+ from the app's own base class.
88
+ - **A `db/schema.rb`.** It is the prompt, so a reading cannot happen before the first
89
+ `db:migrate` has dumped one.
90
+
91
+ ### The options
92
+
93
+ Every setting has a default, so the initializer is optional. `rails generate omen:install`
94
+ writes it with each line commented out, as the list of what there is to say.
95
+
96
+ | Setting | Default |
97
+ |---|---|
98
+ | `record_class` | `'ApplicationRecord'`, falling back to `ActiveRecord::Base` |
99
+ | `reading_role` | `:reading` |
100
+ | `narrow_role` | `'omen_inquirer'` |
101
+ | `claude_model` | `'claude-opus-5'` |
102
+ | `maximum_rows` | `100` |
103
+ | `api_key` | unset, so the Anthropic SDK resolves `ANTHROPIC_API_KEY` and its wider chain |
104
+ | `notes` | none, so the prompt says nothing about this app beyond its schema |
105
+ | `schema_path` | `'db/schema.rb'` |
106
+
107
+ ## What a host builds on top
108
+
109
+ `Omen::Reading` has a `type` column nowhere, so a subclass is a transparent second name for the
110
+ same rows: `Inquiry.all` carries no type condition, and `to_partial_path` becomes
111
+ `inquiries/inquiry`.
112
+
113
+ ```ruby
114
+ class Inquiry < Omen::Reading
115
+ belongs_to :agent
116
+ end
117
+ ```
118
+
119
+ `Omen::Reading.create! question: 'Where are the homes we serve?'` is the whole of asking; a
120
+ follow-up is `reading.ask '...'`. Each question is answered in a job, and the answer carries the
121
+ statement Claude wrote, the rows it found, and which header of theirs held an encrypted column.
122
+
123
+ Two things a subclass cannot reach, because the gem's own class is what a job loads:
124
+ `broadcasts_refreshes`, and anything else that has to be declared on `Omen::Reading` itself. One
125
+ line in the host does it:
126
+
127
+ ```ruby
128
+ Rails.application.config.to_prepare { Omen::Reading.broadcasts_refreshes }
129
+ ```
130
+
131
+ ## After the first deploy
132
+
133
+ The narrow role is granted `SELECT` on every table and then refused Omen's own three, which can
134
+ only happen once those tables exist. On a database that forbids `CREATE ROLE` — a managed one
135
+ usually does — the role is made by hand and the revocation with it:
136
+
137
+ ```sql
138
+ REVOKE SELECT ON omen_readings, omen_questions, omen_answers FROM omen_inquirer;
139
+ ```
140
+
141
+ A missed table there means Claude is shown the log of every question ever asked.
43
142
 
44
143
  ## License
45
144
 
46
- MIT, see [MIT-LICENSE](MIT-LICENSE).
145
+ MIT, see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,37 @@
1
+ # What Claude said back: the prose, the statement it wrote, and the rows that statement found.
2
+ class Omen::Answer < Omen.config.record
3
+ include Omen::Revealed, Omen::Spoken
4
+
5
+ # A \uXXXX escape that outlived JSON.parse, because Claude escaped the backslash of its own.
6
+ UNICODE_ESCAPE = /\\u([0-9a-fA-F]{4})/
7
+
8
+ belongs_to :question
9
+
10
+ # @return [String] the side of the conversation this was said on, in the words the API uses.
11
+ def role = 'assistant'
12
+
13
+ # A reply cut short is not JSON at all, and reads as an answer with nothing in it.
14
+ # @return [Hash] the reply, parsed.
15
+ def answer
16
+ @answer ||= JSON.parse text
17
+ rescue JSON::ParserError
18
+ @answer = {}
19
+ end
20
+
21
+ # @return [Boolean] whether the reply ran out of room, leaving JSON that will not parse.
22
+ def cut_off? = stop_reason == 'max_tokens'
23
+
24
+ # @return [String] the one SELECT Claude wrote, which is empty when it asked instead.
25
+ def sql = answer['sql'].to_s
26
+
27
+ # Decoded here and never in #sql, where a \u may be a literal the statement means to carry.
28
+ # @return [String] what Claude said about the query, or the question it needs answered first.
29
+ def note = answer['note'].to_s.gsub(UNICODE_ESCAPE) { $1.hex.chr Encoding::UTF_8 }
30
+
31
+ # @return [Array<Hash>] the columns Claude asked to be drawn as their parts joined.
32
+ def combine = Array answer['combine']
33
+
34
+ # The query asks for one row past the cap, so the page can say there are more without counting.
35
+ # @return [Boolean] whether the answer ran past what is shown.
36
+ def truncated? = result.size > Omen.config.maximum_rows
37
+ end
@@ -0,0 +1,10 @@
1
+ # Extends Omen::Reading with the way a question is added to it.
2
+ module Omen::Asked extend ActiveSupport::Concern
3
+ # Records what was typed, which is what sets a run going.
4
+ # @param question [String] what the asker wants to know.
5
+ # @return [Omen::Question] the question that was added.
6
+ def ask(question) = questions.create! content: [ { type: 'text', text: question } ]
7
+
8
+ # @return [Boolean] whether Claude is still working on the last question.
9
+ def answering? = (unstarted? || started?) && fresh?
10
+ end
@@ -0,0 +1,60 @@
1
+ # One column of the schema that Rails encrypts before the database is allowed to store it.
2
+ class Omen::Column
3
+ # What stands in for a value this class will not hand over in the clear.
4
+ HIDDEN = '(encrypted)'
5
+
6
+ # A word that makes a name a credential's: `secret`, `api_key`, `otp_salt`, never `surname`.
7
+ CREDENTIALS = /secret|key|password|token|pin|salt|credential|signature/
8
+
9
+ # @return [Array<Omen::Column>] every encrypted column in the app, one per table and name.
10
+ def self.all
11
+ Rails.application.eager_load!
12
+ Omen.config.record.descendants.flat_map do |model|
13
+ model.encrypted_attributes.to_a.map { |attribute| new model, attribute }
14
+ end.uniq(&:name)
15
+ end
16
+
17
+ # An alias in the statement cannot change what Postgres says a value came from.
18
+ # @param result [PG::Result] what the statement answered.
19
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] the one it ran on.
20
+ # @return [Hash] each header of the result that is an encrypted column, to that column's name.
21
+ def self.of(result, connection)
22
+ sources = all.index_by { |column| column.source connection }
23
+ result.nfields.times.each_with_object({}) do |index, into|
24
+ found = sources[[ result.ftable(index), result.ftablecol(index) ]]
25
+ into[result.fname index] = found.name if found
26
+ end
27
+ end
28
+
29
+ # @param model [Class] an Active Record that encrypts the column.
30
+ # @param attribute [Symbol] the attribute it encrypts.
31
+ def initialize(model, attribute)
32
+ @model = model
33
+ @attribute = attribute
34
+ end
35
+
36
+ # @return [String] the table and column, as Postgres names the source of a value.
37
+ def name = "#{@model.table_name}.#{@attribute}"
38
+
39
+ # @return [Boolean] whether a value of this column may be shown in the clear.
40
+ def readable? = !CREDENTIALS.match?(@attribute) && type.scheme.deterministic?
41
+
42
+ # @return [Array<Integer>] the table OID and column number Postgres reports for this column.
43
+ def source(connection)
44
+ described = connection.raw_connection.exec_params probe, []
45
+ [ described.ftable(0), described.ftablecol(0) ]
46
+ end
47
+
48
+ # @return [String, nil] the plaintext, or the placeholder where there is none to be had.
49
+ def read(value)
50
+ readable? ? type.deserialize(value) : HIDDEN
51
+ rescue ActiveRecord::Encryption::Errors::Decryption
52
+ HIDDEN
53
+ end
54
+
55
+ private
56
+
57
+ def type = @model.type_for_attribute @attribute
58
+
59
+ def probe = @model.select(@attribute).limit(0).to_sql
60
+ end
@@ -0,0 +1,56 @@
1
+ # One column a reply asks to be drawn as its parts joined, where the database could not join them.
2
+ class Omen::Combination
3
+ # All a reply may declare: a name, the headers to join, and what goes between them.
4
+ SCHEMA = {
5
+ type: 'array',
6
+ description: 'Columns to draw as one, where the database could not build the value itself ' \
7
+ 'because a part of it is encrypted. Empty where nothing needs joining.',
8
+ items: {
9
+ type: 'object', additionalProperties: false, required: %w[ name parts separator ],
10
+ properties: {
11
+ name: { type: 'string', description: 'The header the joined column is drawn under.' },
12
+ parts: { type: 'array', items: { type: 'string' },
13
+ description: 'Headers of your own query to join, in the order they read.', },
14
+ separator: { type: 'string', description: 'What goes between them, such as ", ".' },
15
+ },
16
+ },
17
+ }
18
+
19
+ # @param declared [Array<Hash>] what the reply asked for.
20
+ # @param headers [Array<String>] the headers the rows really have.
21
+ # @return [Array<Omen::Combination>] the ones a row can be drawn through.
22
+ def self.all(declared, headers)
23
+ declared.map { |one| new one }.select { |combination| combination.over? headers }
24
+ end
25
+
26
+ # @param declared [Hash] one entry of a reply's `combine`.
27
+ def initialize(declared)
28
+ @declared = declared
29
+ end
30
+
31
+ # Answered rather than raised on: the rows are right, and only the presentation was wrong.
32
+ # @param headers [Array<String>] the headers the rows really have.
33
+ # @return [Boolean] whether every part of this names one of them.
34
+ def over?(headers) = parts.any? && parts.all? { |part| headers.include? part }
35
+
36
+ # Joins what Omen::Column#read handed back, less the blanks: HIDDEN is a value, NULL is not.
37
+ # @param row [Hash] one row of the answer, decrypted.
38
+ # @return [Hash] the row with the parts replaced, where the first of them stood.
39
+ def applied(row)
40
+ row.each_with_object({}) do |(header, value), into|
41
+ if header == parts.first
42
+ into[name] = parts.filter_map { |part| row[part].presence }.join separator
43
+ elsif parts.exclude? header
44
+ into[header] = value
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def name = @declared['name'].to_s
52
+
53
+ def parts = Array @declared['parts']
54
+
55
+ def separator = @declared['separator'].to_s
56
+ end
@@ -0,0 +1,51 @@
1
+ # The one place Claude is spoken to, so everything above it is ours to read and to test.
2
+ class Omen::Conversation
3
+ # How many tokens one reply may run to. Thinking is on by default and spends from the same
4
+ # budget, so this is sized well past the JSON it has to leave room for.
5
+ MAX_TOKENS = 16_000
6
+
7
+ # What each kind of block may carry back in, since a reply handed back whole is a 400.
8
+ SENDABLE = {
9
+ 'text' => %w[ type text ],
10
+ 'thinking' => %w[ type thinking signature ],
11
+ 'redacted_thinking' => %w[ type data ],
12
+ 'tool_use' => %w[ type id name input ],
13
+ 'tool_result' => %w[ type tool_use_id content is_error ],
14
+ }
15
+
16
+ # @param questions [ActiveRecord::Relation] the questions asked so far, oldest first, up to
17
+ # and including the one being answered.
18
+ def initialize(questions)
19
+ @questions = questions
20
+ end
21
+
22
+ # No tools are sent, by design: a tool_result block would be rows travelling back to Claude.
23
+ # @return [Hash] the blocks Claude answered with, why it stopped, and what it cost.
24
+ def advance
25
+ said = client.messages.create model: Omen.config.claude_model, max_tokens: MAX_TOKENS,
26
+ system_: Omen::Instructions.block, messages: messages,
27
+ output_config: Omen::Instructions.output_config
28
+
29
+ { content: said.content.map { |block| block.to_h.deep_stringify_keys },
30
+ stop_reason: said.stop_reason, input_usage: said.usage.input_tokens,
31
+ output_usage: said.usage.output_tokens,
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def client = Anthropic::Client.new(**credentials)
38
+
39
+ # Omitted rather than nil, which would lose the SDK's own resolution of a key.
40
+ def credentials = { api_key: Omen.config.api_key }.compact
41
+
42
+ def messages
43
+ spoken.map { |said| { role: said.role, content: said.content.map { |block| sendable block } } }
44
+ end
45
+
46
+ def spoken
47
+ @questions.includes(:answer).flat_map { |question| [ question, question.answer ].compact }
48
+ end
49
+
50
+ def sendable(block) = block.slice(*SENDABLE.fetch(block['type'], block.keys))
51
+ end
@@ -0,0 +1,39 @@
1
+ # Extends Omen::Reading with the one exchange that turns a question into an answer.
2
+ module Omen::Executed extend ActiveSupport::Concern
3
+ # A database message can quote a row, so only the kind of failure is passed on.
4
+ REFUSED = 'so ask it a different way'
5
+
6
+ private
7
+
8
+ # Read again each time round, so a question asked while the API was answering is picked up.
9
+ def run
10
+ with_lock { return if started? && fresh?; started! }
11
+ while (question = questions.where.missing(:answer).first)
12
+ answer question
13
+ end
14
+ completed!
15
+ rescue StandardError => error
16
+ Rails.logger.warn "Reading #{id} failed: #{error.class}"
17
+ failed!
18
+ end
19
+
20
+ def answer(question)
21
+ reply = Omen::Conversation.new(asked_up_to question).advance
22
+ increment! :input_usage, reply[:input_usage]
23
+ increment! :output_usage, reply[:output_usage]
24
+ execute question.create_answer!(**reply)
25
+ end
26
+
27
+ def asked_up_to(question) = questions.where id: ..question.id
28
+
29
+ def execute(answered)
30
+ return if answered.sql.blank?
31
+
32
+ answered.update! Omen::Query.new(answered.sql).answer
33
+ rescue Omen::Role::Unavailable => error
34
+ answered.update! error: error.message
35
+ rescue StandardError => error
36
+ Rails.logger.warn "Reading #{id} refused a statement: #{error.class}"
37
+ answered.update! error: "#{(error.cause || error).class}, #{REFUSED}"
38
+ end
39
+ end
@@ -0,0 +1,92 @@
1
+ You answer questions about the data an app holds, for the staff who run it.
2
+
3
+ You answer by writing **one PostgreSQL SELECT** against the schema at the end of this message,
4
+ and a short note about it. Nothing else runs.
5
+
6
+ ## You will never see the results
7
+
8
+ Rails runs your query and shows the rows to the person who asked. They come back to you never.
9
+ So do not offer to interpret them, do not say what the answer will turn out to be, and do not
10
+ plan a second query that depends on the first one's output. Write the query that answers the
11
+ question outright.
12
+
13
+ Because nobody translates the rows for you, make them readable on their own: join a `*_id` to
14
+ the table it points at and return the name held there rather than the bare integer, and name
15
+ every computed column with `AS` so its header says what it is.
16
+
17
+ ## The connection
18
+
19
+ Read-only, and a statement that would write is refused rather than run. Write exactly one
20
+ statement: Postgres runs one, and refuses anything that would follow it. Your statement is
21
+ wrapped in `SELECT * FROM (your query) AS answer LIMIT n`, and no more than a page of rows
22
+ comes back however you write it. A `WITH` clause is fine.
23
+
24
+ Prefer `count(*)`, `group by` and aggregates over returning raw rows: a question about how many
25
+ or about which is most is answered better by ten rows than by a thousand.
26
+
27
+ ## Reading the schema
28
+
29
+ Timestamps are stored in UTC, and `%{eastern}()` is the one way to read one: it hands the same
30
+ moment back in the zone the company works in. Wrap every timestamp you touch in it -- in a
31
+ `WHERE`, in an `ORDER BY`, in a `GROUP BY`, and in a column you return -- so a question about a
32
+ day, a week or a month means whole days here: `date_trunc('day', %{eastern}(created_at))`. Never
33
+ write a conversion of your own.
34
+
35
+ Today is %{today}. Resolve every relative date yourself; the query has no idea what "last
36
+ month" means.
37
+
38
+ Every type named in a `create_enum` line at the top of the schema is a Postgres enum, and the
39
+ values it may take are listed on that line. Compare one as text, for example
40
+ `WHERE status::text = 'fulfilled'`.
41
+
42
+ Rails encrypts these columns before they are stored, and decrypts them again for the person
43
+ reading the page: %{readable}. Select them freely.
44
+
45
+ Do not filter, sort or transform one. The database only ever sees the ciphertext, so
46
+ `WHERE street = '2 Rodeo Dr'` matches nothing and `upper(street)` comes back unreadable --
47
+ filter and sort on columns stored in the clear instead. `GROUP BY` one does work, because
48
+ equal values encrypt equally, so "how many homes per street" is answerable.
49
+
50
+ Where the value somebody wants is one of these joined to something else -- a street with the
51
+ city and state after it -- select the parts and declare the join in `combine`, for example
52
+ `[{"name": "address", "parts": ["street", "rest"], "separator": ", "}]`. Rails joins them after
53
+ decrypting, and the page draws one column under the name you gave, in place of its parts. Every
54
+ entry of `parts` has to be a header your query really returns. Where nothing needs joining,
55
+ `combine` is `[]` -- and never explain a join in `note` instead of declaring it.
56
+
57
+ These columns are stored encrypted as well, and no page ever reads one back -- either the name
58
+ reads as a credential's, or the value is encrypted in a way no two writes of it agree on -- so
59
+ do not select them: %{refused}.
60
+
61
+ Every other column is stored in the clear and is yours to use.
62
+
63
+ Only unique indexes are listed, and one tells you that a column, or a combination of them,
64
+ identifies at most one row -- worth knowing before you reach for `DISTINCT` or a `GROUP BY`.
65
+ Nothing follows from an index not being there.
66
+
67
+ %{notes}
68
+
69
+ ## Tables whose `type` column holds a Ruby class name
70
+
71
+ Single-table inheritance: the value is a class name, so `WHERE type = 'estimate'` matches
72
+ nothing rather than erroring. The exact values are:
73
+
74
+ %{types}
75
+
76
+ A `type` column not listed above is an ordinary string and means whatever the rows say.
77
+
78
+ ## When the question is not clear enough to answer
79
+
80
+ If a question could be read more than one way, or is missing the one thing you would need to
81
+ pick a window of days, answer with an **empty** `sql` and put the question you need answered
82
+ into `note`. Do not guess. A question with no date range, on data that spans years, is usually
83
+ one of those.
84
+
85
+ Otherwise keep the note to a sentence or two: what the query returns, and any assumption you
86
+ made. These are colleagues reading quickly, not a report.
87
+
88
+ ## The schema
89
+
90
+ ```ruby
91
+ %{schema}
92
+ ```
@@ -0,0 +1,56 @@
1
+ # Everything Claude is told before a question: the prose, the schema, and the shape of a reply.
2
+ class Omen::Instructions
3
+ # The prose, kept beside this class so it reads as prose rather than as a string.
4
+ PROSE = File.expand_path 'instructions.md', __dir__
5
+
6
+ # The database function a stored timestamp is read through, created by the gem's rake task.
7
+ EASTERN = 'eastern'
8
+
9
+ # The one shape a reply may take: both keys required, and no others admitted.
10
+ ANSWER = {
11
+ type: 'object', additionalProperties: false, required: %w[ sql note combine ],
12
+ properties: {
13
+ sql: { type: 'string',
14
+ description: 'The one PostgreSQL SELECT that answers the question, or ' \
15
+ 'empty to ask something first.', },
16
+ note: { type: 'string',
17
+ description: 'A sentence or two: what the query returns and any ' \
18
+ 'assumption made. If sql is empty, the question you ' \
19
+ 'need answered first.', },
20
+ combine: Omen::Combination::SCHEMA,
21
+ },
22
+ }
23
+
24
+ # @return [Hash] what a reply is constrained to, so that it always parses.
25
+ def self.output_config = { format_: { type: :json_schema, schema: ANSWER } }
26
+
27
+ # Cached for an hour: the schema is thousands of tokens, and refining sends it again.
28
+ # @return [Array<Hash>] the one system block of a request.
29
+ def self.block
30
+ [ { type: 'text', text: new.text, cache_control: { type: 'ephemeral', ttl: '1h' } } ]
31
+ end
32
+
33
+ # Today's date is said out loud because "last month" is Claude's to resolve, and it has no clock.
34
+ # @return [String] the prose, with the schema, the subclasses and the host's notes filled in.
35
+ def text
36
+ format File.read(PROSE), today: Date.current.to_fs(:long), eastern: EASTERN,
37
+ schema: schema, types: types, readable: readable, refused: refused,
38
+ notes: Omen.config.notes
39
+ end
40
+
41
+ private
42
+
43
+ def schema = Omen::Schema.new.text
44
+
45
+ def types
46
+ Rails.application.eager_load!
47
+ Omen.config.record.descendants.select(&:finder_needs_type_condition?)
48
+ .group_by(&:table_name).sort.map { |table, kinds| "- `#{table}`: #{named kinds}" }.join "\n"
49
+ end
50
+
51
+ def named(kinds) = kinds.map(&:name).sort.map { |name| "`#{name}`" }.join ', '
52
+
53
+ def readable = named Omen::Column.all.select(&:readable?)
54
+
55
+ def refused = named Omen::Column.all.reject(&:readable?)
56
+ end
@@ -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