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 +4 -4
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +108 -9
- data/app/models/omen/answer.rb +37 -0
- data/app/models/omen/asked.rb +10 -0
- data/app/models/omen/column.rb +60 -0
- data/app/models/omen/combination.rb +56 -0
- data/app/models/omen/conversation.rb +51 -0
- data/app/models/omen/executed.rb +39 -0
- data/app/models/omen/instructions.md +92 -0
- data/app/models/omen/instructions.rb +56 -0
- data/app/models/omen/query.rb +49 -0
- data/app/models/omen/question.rb +13 -0
- data/app/models/omen/reading.rb +18 -0
- data/app/models/omen/revealed.rb +30 -0
- data/app/models/omen/role.rb +29 -0
- data/app/models/omen/schema.rb +26 -0
- data/app/models/omen/spoken.rb +5 -0
- data/app/models/omen/stated.rb +21 -0
- data/db/migrate/20260821120000_create_omen_readings.rb +13 -0
- data/db/migrate/20260821120001_create_omen_questions.rb +9 -0
- data/db/migrate/20260821120002_create_omen_answers.rb +16 -0
- data/lib/generators/omen/install/install_generator.rb +31 -0
- data/lib/generators/omen/install/templates/omen.rb +35 -0
- data/lib/omen/config.rb +46 -0
- data/lib/omen/eastern.rb +22 -0
- data/lib/omen/engine.rb +10 -0
- data/lib/omen/inquirer.rb +91 -0
- data/lib/omen/requirements.rb +37 -0
- data/lib/omen/stubs.rb +41 -0
- data/lib/omen/version.rb +1 -1
- data/lib/omen.rb +17 -0
- data/lib/tasks/omen.rake +9 -0
- metadata +73 -2
- data/Rakefile +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0439ba57f67515df5b1685bb241e7c8d904f7039b3c349a45add84eaec5b6330'
|
|
4
|
+
data.tar.gz: 125790550dbc3c82b2a139c4a09fc1767ea6776ce984357aa40841e397adef38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
10
|
-
|
|
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.
|
|
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.
|
|
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
|
-
**
|
|
42
|
-
|
|
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 [
|
|
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
|