truffler 0.1.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 +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +425 -0
- data/bench/cassettes/004cad52c71fc8d847bf388eb9c2c288e1169b65ec4a28ce040cb7fea7c55f04.json +1 -0
- data/bench/cassettes/0547d3d23c3fbaae86772ffa0e4e96475aa152a2556b8fa4bc7568effcb81a28.json +1 -0
- data/bench/cassettes/07aa23d166ab3500178e3f3c0838183051b093507bef88e1e2d29efdf5759ee6.json +1 -0
- data/bench/cassettes/0858c5006c0bfac3e970ddaff3c139f6c110dbe3d8539fd95263706c637cdc8e.json +1 -0
- data/bench/cassettes/0c0eb7c27d76bd0b9263690a63cd92d6b68c305826318555fd5b084a09970b78.json +1 -0
- data/bench/cassettes/0fada0c0a103882e2895f7f455d73d2cbe2b4a81bf743bb19b2b4f6e0130126b.json +1 -0
- data/bench/cassettes/1117d79ac7c43b21beaf75e510461f91cd4ecc3cdbbe461f1794644af4aaf8f3.json +1 -0
- data/bench/cassettes/11b7444a074cca4d0ef1535db0cf0edefe4c34eac4be3c593c9c2f33d3b038b8.json +1 -0
- data/bench/cassettes/11d00371d1b993675be00463159164536f1f8ed90a48652b0935eb9c36f8b684.json +1 -0
- data/bench/cassettes/1456ebe12616ce961d68a14eada62778a7271ebd3d1b4cb6eefd6857651c7afc.json +1 -0
- data/bench/cassettes/14c3693f64b0e9ea7e470277fccae6ab501d404185eec5bb9074ef4b75c572c0.json +1 -0
- data/bench/cassettes/14fe9f6d8055d41f5d970dc32ab640c213ea8782d365c82d32b9884ba4f28c67.json +1 -0
- data/bench/cassettes/1a1d8557e4698426c2467aa573a240357715d3d9e3fa77bf066672a8b6bf9a86.json +1 -0
- data/bench/cassettes/1b5a8aaaf98305560588246cd51f53c6c185a951f8018c26bf588954d9de1cea.json +1 -0
- data/bench/cassettes/1fe596be747e33e9c7e4d7c50de32613957ac66bee43679b4bed96c551b1fd0a.json +1 -0
- data/bench/cassettes/21ebce6db6d39b1747dccf25bd3ceb298e1285425190b4d78830c395a7ad786a.json +1 -0
- data/bench/cassettes/23066253621e9cb00fd93cbe95ef1124ff0715585fcb6325b3abbd0802b2c303.json +1 -0
- data/bench/cassettes/2f6576325dfab696cec8d8b35fa89c29960bee15d10dab31c91cef507f4842be.json +1 -0
- data/bench/cassettes/33ec27d22c0f63dbbe93008f50af07fa8667a5c806a9fbfc7897683915412b40.json +1 -0
- data/bench/cassettes/351f745b0c47e0cf3272b0fea7fc4941b6aa733f013c55a9a19707b542543200.json +1 -0
- data/bench/cassettes/37d974fdc0c1876413111c244d363c8d2306e3229fec668c158b5c924ffdb983.json +1 -0
- data/bench/cassettes/39524467463dd612cbf03e314b9cfd8cbe1c5f06969da2289359cf7cf83de5da.json +1 -0
- data/bench/cassettes/3f2c758c111ab7f1163ba579f2329718afe15620507d356d3ebe2a053df237ea.json +1 -0
- data/bench/cassettes/44bbc3a3f0ceb0edb443b080b939af80dd145795505bd13cac113a35365d0f04.json +1 -0
- data/bench/cassettes/45bf701a13c348acb45a3b3a56990570e3caf797c3daee5d1358a477593ce587.json +1 -0
- data/bench/cassettes/4676a0f2df29493fdfa0b7cd75c097e647373862d88373dc0e1fa9fca26707c2.json +1 -0
- data/bench/cassettes/49708a50e3c78f45beed2f1b8d8d5375b99ea4ab3497fed7650e6ece4ec81ba8.json +1 -0
- data/bench/cassettes/54ea30ecca8fa49f607d5c8265296eb16edb48d0620a7399c6480d4cb0f43ae1.json +1 -0
- data/bench/cassettes/550e73a8291f53d0f2b008f166560eff4d10cc7c09a927d3c4f70544f2578300.json +1 -0
- data/bench/cassettes/570039823d5af1b6c1d4bcbe14f9802d40e851f8e4946278a46884c76c0ceee1.json +1 -0
- data/bench/cassettes/5ae9dce82fa7b9084b592f657a24acfd5852c4fcb091a6a8e22f2cbbf947bcdd.json +1 -0
- data/bench/cassettes/5c40794d3bf6e229c6ffd26922388776121f971ef27279e02a35ce004e0794dd.json +1 -0
- data/bench/cassettes/62f2e71a2d9cfdc18fdd6600f5a1450519b25a75c2f799b7695258627250497d.json +1 -0
- data/bench/cassettes/642becd7ad9cd4cc2ac6d389ef8ff14fd9fe125beca834afee625ea2c31476be.json +1 -0
- data/bench/cassettes/656cc1158890e928c129d9cd54c1e08af1b6a2c0f6f94bacf395b13a198dc81f.json +1 -0
- data/bench/cassettes/67b30efd5681046fb5f2252d73f9aedf603be1b7c7895e4e71c6cfb88238309e.json +1 -0
- data/bench/cassettes/691958dd39537d72c4b0ab4ba69fea3c51b8e06a49a772f3ffd5bb5747dfe99d.json +1 -0
- data/bench/cassettes/6931e51f0455b5e3260165aa22a445d3a4f782ae5233a961384149cfbb045fe0.json +1 -0
- data/bench/cassettes/6b79cf1a80fc9edd09bb4606613ff2de2ab2bc61487983dffac1174025270997.json +1 -0
- data/bench/cassettes/6d683884d5d039db072ac18c9420bdcc989838f08a71096076cccc2edcd48234.json +1 -0
- data/bench/cassettes/6df49dc7eae4104adf1b020fd846d893da3e49f4f13a4c6fd1f87a78c410ba31.json +1 -0
- data/bench/cassettes/75cc5d99bbc1d48ace6095d41daae1e2e2c829291f01d40f0ce40d6eea52c0bf.json +1 -0
- data/bench/cassettes/771a86cbb6c025101384a50d01a4745683e1c2b17bbfff73e40e475dbf016efb.json +1 -0
- data/bench/cassettes/7a2a93c357473f4a289a6ec250e3bfe6f51152a0c448e5420ffebee7872f9d11.json +1 -0
- data/bench/cassettes/7a4774a02c3eebe24d4fbd443ea90661711fe5cb8248bce7a2dedfedf6ddbc03.json +1 -0
- data/bench/cassettes/7c1fa04f8eb43251ca3f6652977b698e4e6ba5e120da8301b4dbce79ef01ccb9.json +1 -0
- data/bench/cassettes/7c4dea48c2bc2c413da81307ce3cbb2749b20e16e4d831fca5861decf11ec55b.json +1 -0
- data/bench/cassettes/7dd499da389eaeeb59eba533695c20fd1a43e25f89f21e3456b826f9953b2010.json +1 -0
- data/bench/cassettes/80438d0bbfe106baa236eef358e46260693e6491f67e7a9f8ea1076c6fbfd449.json +1 -0
- data/bench/cassettes/80db562534181514f243ac74d7ddaec1fafc28b2b72ae35482cfc2de8f2d9e61.json +1 -0
- data/bench/cassettes/825a51543665a468a55c823788c68eb5785c90be8131988f72bf98f4f0294177.json +1 -0
- data/bench/cassettes/83d8276c227b73e542bd70c023d9a8ffa1c2cf4dd78e71d61457f097bc743d19.json +1 -0
- data/bench/cassettes/891405ffd7727b6d4c4186e2c60a08aa8b62c7b711bd1558c8033fcf8e4178a4.json +1 -0
- data/bench/cassettes/89fabff05dc4c0abdf630781a324b7802cbc0dc8bd09b20ce59643465a2aeaec.json +1 -0
- data/bench/cassettes/8cbceece3ca6136b6c6bb71c9bf28f409a5fa070596ebfc0f3187aba287bf6d3.json +1 -0
- data/bench/cassettes/8d56da27350d9dea20468eae30fa51cbbb10637c06d977b3e1a00734966be46f.json +1 -0
- data/bench/cassettes/8fb43b452b5e15248f34bf48e41cfc227129cfe349fe4b3fc4ab7133d66914bc.json +1 -0
- data/bench/cassettes/92e5426bd0c1f31d7acca92f77d7f2586e74054467729f0be28d139f515354d5.json +1 -0
- data/bench/cassettes/938be225155e1952b817652778b7cab377017fc4a219628ae89ad01a9c1151bc.json +1 -0
- data/bench/cassettes/9519ad701b28e7b2c5f1e1cad5df1ec3e5254c8cec969b9bee1361af8803d39d.json +1 -0
- data/bench/cassettes/9536e8ed68330eda400d765c3ca16a4dd3bfe0d026b569599a33e7e4951f4860.json +1 -0
- data/bench/cassettes/99c6e5aefd97722f4028bacc11d299eb9036805cd5cf7bf75f879ebe3f27c00a.json +1 -0
- data/bench/cassettes/9cdfd2f3ef8dc4cde917a9d196e3137159067c10ec8d2132f32da034865f9959.json +1 -0
- data/bench/cassettes/a1e3030984d4e1982fb5a2b2068664187358c88c6b71f768841d3e78406e9535.json +1 -0
- data/bench/cassettes/a2140f51b8fa25da73efd387a41f9eb84ffbc1f1ab47dec0c3b37b249ff373a0.json +1 -0
- data/bench/cassettes/a36d248ce5c26fd66430b780c8ecdd176f29830ac4edb2bf0b2e07cb93ffb865.json +1 -0
- data/bench/cassettes/a64329816ca1e537e90b89862a86be119e1e6236c6f3cf0141a96505b0734364.json +1 -0
- data/bench/cassettes/aba24c67dcaefa51c204a529b5ea3fe01333181a608a8ab8899568c731677e7d.json +1 -0
- data/bench/cassettes/aed233524926f2b4ff8c2ec21224bd284595c91eb57e0fb6981cdc8ac7e7d8d3.json +1 -0
- data/bench/cassettes/b00e9da6403e48aa1f4f9346419f88b2a336e1a2ac4a28515d84b2337eb777b2.json +1 -0
- data/bench/cassettes/b02f4c76c073882ff0fd12fddc28e4db52d9e6486a130194bf1057c7693de831.json +1 -0
- data/bench/cassettes/b6a9cb89ae4afd08a6919eaf7f609790c895d4cf22d47dad6c3b287b9278be87.json +1 -0
- data/bench/cassettes/b71b982225eab48d02c81913877571dfa53717c0983d1d72cbe3787242dda055.json +1 -0
- data/bench/cassettes/bda5e115b566e7f955aa45222859fe4f725949d5f91e3d794821701491448510.json +1 -0
- data/bench/cassettes/c0ac703268faf8d49dbc1ad0e877693a9f52181ae6070de844fcd4218d33d5e4.json +1 -0
- data/bench/cassettes/c0b57bcbb4004edd44c2d8c7e1f8cfc3567d954a3d5b21b2cda7257e3bc07437.json +1 -0
- data/bench/cassettes/d1ade9adde20981211c470a77b4d560264cae290941e692be6876a79227e3d18.json +1 -0
- data/bench/cassettes/d28de3038544a5bf9c8425b6976e360fae71c5ca96409784ad6642703701986a.json +1 -0
- data/bench/cassettes/d6418acb9f7f58ffdf3049481cbd17cfb3f0581064e157c9c58c19a3fc26b4d4.json +1 -0
- data/bench/cassettes/d71e8cf7c2be4b6e4089eb40c5a108c0c45fc58398af5138f905129f640afbb8.json +1 -0
- data/bench/cassettes/d9672c45b5358b92f7740abfb20b287235cf949990c88558f41d3a9c00eb6d3b.json +1 -0
- data/bench/cassettes/da0c9ce695111f6acf3cfb58dc0494f79230739d1773377db420bf438fd2e244.json +1 -0
- data/bench/cassettes/db57e3a15971cd2832313172c0d7df6e6a48b13a276ba9877eeb7d39389d74d3.json +1 -0
- data/bench/cassettes/db9c8c4d0a8d855a2578572afe5edc5eebb39c0fd5f04489614ea2557ec4f0cd.json +1 -0
- data/bench/cassettes/e2c396b0475a07a615dde9208b088e9e479720c1ec5889ade07fbfd0d28fb517.json +1 -0
- data/bench/cassettes/e3006ccc97646f43f4ea23694665526e47982fa1ea413359b9669071f6cb990d.json +1 -0
- data/bench/cassettes/e623737636e797d22f80d128cbff46101837044e159f41fa62a8d3d0cc6bb996.json +1 -0
- data/bench/cassettes/ea0cefaaac1a62e8eac962391df025a3b14c3a3d5b69fe57d60b65811010908c.json +1 -0
- data/bench/cassettes/ee3e06456cf4201ab46f579dd53bdea4071ce43bfe02e9b870bcb4b9c1c680b4.json +1 -0
- data/bench/cassettes/f26e675da9088f08cb76dd6b3fd89230b84fd1b9be3767c36857217bda2da15b.json +1 -0
- data/bench/cassettes/f396f34b2356d42af385eac9f5760e97e553a584c3f93da7bd618ad311e04d93.json +1 -0
- data/bench/cassettes/fce02c5c04cb8c27e868f6c54cfcbbbf69bde2f7f612d50ff8fb6d0aec9df3e3.json +1 -0
- data/bench/cassettes/ffc09a04b083822c1597dd96bb27ee4cde365bc8346175ebf6e378ab2ab1b598.json +1 -0
- data/bench/cassettes/manifest.json +1 -0
- data/bench/fixtures/gold.jsonl +27 -0
- data/bench/fixtures/injection.jsonl +6 -0
- data/bench/fixtures/records.jsonl +90 -0
- data/bench/params.yml +30 -0
- data/lib/generators/truffler/install/install_generator.rb +51 -0
- data/lib/generators/truffler/install/templates/channel.rb.tt +17 -0
- data/lib/generators/truffler/install/templates/initializer.rb.tt +23 -0
- data/lib/generators/truffler/install/templates/migration.rb.tt +109 -0
- data/lib/tasks/truffler/bench.rake +30 -0
- data/lib/tasks/truffler/suggestions.rake +8 -0
- data/lib/tasks/truffler.rake +26 -0
- data/lib/truffler/answers.rb +66 -0
- data/lib/truffler/benchmark/adapters.rb +80 -0
- data/lib/truffler/benchmark/database.rb +31 -0
- data/lib/truffler/benchmark/dataset.rb +65 -0
- data/lib/truffler/benchmark/email.rb +50 -0
- data/lib/truffler/benchmark/generator.rb +188 -0
- data/lib/truffler/benchmark/metrics.rb +62 -0
- data/lib/truffler/benchmark/params.rb +91 -0
- data/lib/truffler/benchmark/runner.rb +370 -0
- data/lib/truffler/benchmark/synthetic_jev.rb +85 -0
- data/lib/truffler/benchmark.rb +29 -0
- data/lib/truffler/broadcaster.rb +46 -0
- data/lib/truffler/budget.rb +118 -0
- data/lib/truffler/canonical.rb +27 -0
- data/lib/truffler/clients/base.rb +49 -0
- data/lib/truffler/clients/callable.rb +26 -0
- data/lib/truffler/clients/cassette.rb +44 -0
- data/lib/truffler/clients/fake.rb +85 -0
- data/lib/truffler/clients/ruby_llm_typesafe.rb +32 -0
- data/lib/truffler/configuration.rb +89 -0
- data/lib/truffler/definition.rb +176 -0
- data/lib/truffler/embeddings/backfill.rb +48 -0
- data/lib/truffler/embeddings/column_store.rb +65 -0
- data/lib/truffler/embeddings/embedder.rb +53 -0
- data/lib/truffler/embeddings/fake_embedder.rb +45 -0
- data/lib/truffler/embeddings/label_vector.rb +81 -0
- data/lib/truffler/embeddings/neighbor_store.rb +66 -0
- data/lib/truffler/embeddings/ruby_llm_embedder.rb +25 -0
- data/lib/truffler/embeddings/ruby_store.rb +12 -0
- data/lib/truffler/embeddings/vector_store.rb +84 -0
- data/lib/truffler/embeddings.rb +30 -0
- data/lib/truffler/errors.rb +40 -0
- data/lib/truffler/instrumentation.rb +17 -0
- data/lib/truffler/jobs/backfill_job.rb +45 -0
- data/lib/truffler/jobs/embed_job.rb +33 -0
- data/lib/truffler/jobs/encode_query_job.rb +17 -0
- data/lib/truffler/jobs/expire_lenses_job.rb +15 -0
- data/lib/truffler/jobs/label_flush_job.rb +39 -0
- data/lib/truffler/jobs/lens_backfill_job.rb +33 -0
- data/lib/truffler/jobs/provider_search_job.rb +18 -0
- data/lib/truffler/jobs/prune_query_misses_job.rb +15 -0
- data/lib/truffler/jobs/rerank_chunk_job.rb +14 -0
- data/lib/truffler/jobs/resume_job.rb +61 -0
- data/lib/truffler/jobs/smart_search_job.rb +14 -0
- data/lib/truffler/label_definition.rb +153 -0
- data/lib/truffler/labeling/backfill.rb +189 -0
- data/lib/truffler/labeling/labeler.rb +161 -0
- data/lib/truffler/labeling/queue.rb +125 -0
- data/lib/truffler/labeling/request_builder.rb +87 -0
- data/lib/truffler/labeling/supplied.rb +70 -0
- data/lib/truffler/lenses/activator.rb +51 -0
- data/lib/truffler/lenses/backfill.rb +112 -0
- data/lib/truffler/lenses/draft.rb +16 -0
- data/lib/truffler/lenses/drafter.rb +147 -0
- data/lib/truffler/lenses/fake_generator.rb +58 -0
- data/lib/truffler/lenses/lens.rb +161 -0
- data/lib/truffler/lenses/lens_label.rb +51 -0
- data/lib/truffler/lenses/policy.rb +46 -0
- data/lib/truffler/lenses/previewer.rb +217 -0
- data/lib/truffler/lenses/proposer.rb +53 -0
- data/lib/truffler/lenses/ruby_llm_generator.rb +42 -0
- data/lib/truffler/lenses/scope.rb +35 -0
- data/lib/truffler/lenses/sealed_description.rb +29 -0
- data/lib/truffler/lenses/settings.rb +45 -0
- data/lib/truffler/lenses/validation.rb +69 -0
- data/lib/truffler/lenses/version.rb +34 -0
- data/lib/truffler/lenses.rb +138 -0
- data/lib/truffler/misses/clusterer.rb +69 -0
- data/lib/truffler/misses/recorder.rb +29 -0
- data/lib/truffler/misses/suggestions.rb +49 -0
- data/lib/truffler/misses.rb +83 -0
- data/lib/truffler/model.rb +111 -0
- data/lib/truffler/providers/backup.rb +58 -0
- data/lib/truffler/providers/runner.rb +67 -0
- data/lib/truffler/providers.rb +57 -0
- data/lib/truffler/query_encoding/cache.rb +71 -0
- data/lib/truffler/query_encoding/encoder.rb +183 -0
- data/lib/truffler/query_encoding/prefetch.rb +34 -0
- data/lib/truffler/query_encoding.rb +12 -0
- data/lib/truffler/questions.rb +78 -0
- data/lib/truffler/railtie.rb +11 -0
- data/lib/truffler/records/embedding.rb +39 -0
- data/lib/truffler/records/label.rb +11 -0
- data/lib/truffler/records/query_miss.rb +20 -0
- data/lib/truffler/records/record_state.rb +30 -0
- data/lib/truffler/redaction.rb +24 -0
- data/lib/truffler/registry.rb +16 -0
- data/lib/truffler/search/encoding.rb +84 -0
- data/lib/truffler/search/encoding_cache.rb +68 -0
- data/lib/truffler/search/keystroke.rb +149 -0
- data/lib/truffler/search/query.rb +67 -0
- data/lib/truffler/search/result.rb +100 -0
- data/lib/truffler/search/sql.rb +197 -0
- data/lib/truffler/smart_search/dispatcher.rb +88 -0
- data/lib/truffler/smart_search/reranker.rb +105 -0
- data/lib/truffler/smart_search/run.rb +306 -0
- data/lib/truffler/smart_search/starter.rb +73 -0
- data/lib/truffler/smart_search/store.rb +89 -0
- data/lib/truffler/smart_search.rb +43 -0
- data/lib/truffler/tokens.rb +12 -0
- data/lib/truffler/usage.rb +7 -0
- data/lib/truffler/version.rb +3 -0
- data/lib/truffler/vocabulary.rb +48 -0
- data/lib/truffler.rb +41 -0
- metadata +336 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Lenses
|
|
3
|
+
# A named set of drafted label questions for one model and scope (R39).
|
|
4
|
+
# The row mirrors its active version's questions, so search-time
|
|
5
|
+
# visibility needs no join. Statuses: draft (never activated), proposed
|
|
6
|
+
# (system-drafted from misses, awaiting approval), active, expired.
|
|
7
|
+
class Lens < ActiveRecord::Base
|
|
8
|
+
include SealedDescription
|
|
9
|
+
|
|
10
|
+
self.table_name = "truffler_lenses"
|
|
11
|
+
|
|
12
|
+
STATUSES = %w[draft proposed active expired].freeze
|
|
13
|
+
|
|
14
|
+
HistoryEntry = Data.define(:number, :status, :label_keys, :reused, :restored_from, :created_by_digest, :created_at,
|
|
15
|
+
:activated_by_digest, :activated_at)
|
|
16
|
+
|
|
17
|
+
has_many :versions, -> { order(:number) }, class_name: "Truffler::Lenses::Version", foreign_key: :lens_id,
|
|
18
|
+
inverse_of: :lens, dependent: :delete_all
|
|
19
|
+
belongs_to :active_version, class_name: "Truffler::Lenses::Version", optional: true
|
|
20
|
+
serialize :questions, coder: JSON
|
|
21
|
+
serialize :reused_keys, coder: JSON
|
|
22
|
+
|
|
23
|
+
validates :status, inclusion: { in: STATUSES }
|
|
24
|
+
validates :scope_type, inclusion: { in: %w[app tenant user] }
|
|
25
|
+
|
|
26
|
+
scope :active, -> { where(status: "active") }
|
|
27
|
+
scope :proposed, -> { where(status: "proposed") }
|
|
28
|
+
scope :for_model, ->(model) { where(record_type: model.polymorphic_name) }
|
|
29
|
+
|
|
30
|
+
after_commit { Lenses.changed!(record_type) }
|
|
31
|
+
|
|
32
|
+
def self.build_from(draft, status:, origin: "user", creator_digest: nil, proposal_digest: nil)
|
|
33
|
+
new(record_type: draft.model.polymorphic_name, scope_type: draft.scope.type.to_s, scope_key: draft.scope.scope_key,
|
|
34
|
+
tenant_key: draft.scope.tenant_key, name: draft.name, description: draft.description, status: status,
|
|
35
|
+
origin: origin, creator_digest: creator_digest, proposal_digest: proposal_digest,
|
|
36
|
+
spend_cap_usd: Lenses.settings.spend_cap_usd)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Active lenses nobody has searched with for `after` stop applying
|
|
40
|
+
# (R43). Their stored label rows stay until pruned.
|
|
41
|
+
def self.expire_unused!(now: Time.current, after: Lenses.settings.expire_after)
|
|
42
|
+
unused = active.where("COALESCE(last_used_at, updated_at) < ?", now - after)
|
|
43
|
+
record_types = unused.distinct.pluck(:record_type)
|
|
44
|
+
unused.update_all(status: "expired", updated_at: now).tap { record_types.each { |type| Lenses.changed!(type) } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def model
|
|
48
|
+
record_type.safe_constantize
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def scope
|
|
52
|
+
Scope.new(type: scope_type.to_sym, tenant_key: tenant_key, user_digest: (scope_key if scope_type == "user"))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def active? = status == "active"
|
|
56
|
+
def proposed? = status == "proposed"
|
|
57
|
+
def expired? = status == "expired"
|
|
58
|
+
|
|
59
|
+
# {"lens:<id>:<label>" => wire-shape question} for the active version.
|
|
60
|
+
def storage_questions
|
|
61
|
+
questions.to_h.transform_keys { |label| Lenses.label_key(id, label) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# {"lens:<id>:<label>" => LensLabel} for the active version.
|
|
65
|
+
def labels
|
|
66
|
+
questions.to_h.to_h { |label, question| [ Lenses.label_key(id, label), LensLabel.new(id, label, question) ] }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The truffler_labels keys this lens writes: one per noul or score, one
|
|
70
|
+
# per option for choices.
|
|
71
|
+
def storage_keys
|
|
72
|
+
questions.to_h.flat_map do |label, question|
|
|
73
|
+
key = Lenses.label_key(id, label)
|
|
74
|
+
question["type"] == "choice" ? question["criteria"].keys.map { |option| "#{key}:#{option}" } : [ key ]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def remaining_spend
|
|
79
|
+
spend_cap_usd && [ spend_cap_usd - spent_usd.to_f, 0.0 ].max
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def spend_cap_reached?
|
|
83
|
+
spend_cap_usd.present? && spent_usd.to_f >= spend_cap_usd
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def would_exceed_cap?(cost)
|
|
87
|
+
spend_cap_usd.present? && spent_usd.to_f + cost > spend_cap_usd
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Adds Jev spend atomically, so concurrent previews and backfill jobs
|
|
91
|
+
# never lose an increment.
|
|
92
|
+
def record_spend!(cost)
|
|
93
|
+
return if cost.to_f.zero?
|
|
94
|
+
|
|
95
|
+
self.class.where(id: id).update_all([ "spent_usd = spent_usd + ?, updated_at = ?", cost.to_f, Time.current ])
|
|
96
|
+
self.spent_usd = self.class.where(id: id).pick(:spent_usd)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def draft_version
|
|
100
|
+
versions.select(&:draft?).last
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def add_version!(draft, by:, restored_from: nil)
|
|
104
|
+
versions.create!(number: versions.maximum(:number).to_i + 1, description: draft.description, questions: draft.questions,
|
|
105
|
+
reused_keys: draft.reused, fingerprint: draft.fingerprint, created_by_digest: Policy.digest_for(by),
|
|
106
|
+
status: "draft", restored_from: restored_from)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Drafts a new version from the same or an edited description. Search
|
|
110
|
+
# keeps using the active version until the draft is activated.
|
|
111
|
+
def regenerate(by:, description: nil, drafter: Drafter.new)
|
|
112
|
+
Policy.authorize!(by, scope, model: model)
|
|
113
|
+
text = description.presence || self.description
|
|
114
|
+
raise InvalidLens, "lens #{id} has no readable description; pass description:" if text.blank?
|
|
115
|
+
|
|
116
|
+
add_version!(drafter.draft(text, model: model, scope: scope, lens: self), by: by)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Makes an earlier version's questions active again as a new version
|
|
120
|
+
# that records where it came from, so history keeps every change.
|
|
121
|
+
def restore!(number, by:)
|
|
122
|
+
source = versions.find_by!(number: number)
|
|
123
|
+
Policy.authorize!(by, scope, model: model)
|
|
124
|
+
Activator.activate(add_version!(source.to_draft, by: by, restored_from: source.number), by: by)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def history
|
|
128
|
+
versions.reload.map do |version|
|
|
129
|
+
HistoryEntry.new(number: version.number, status: version.status, label_keys: version.questions.to_h.keys,
|
|
130
|
+
reused: Array(version.reused_keys), restored_from: version.restored_from,
|
|
131
|
+
created_by_digest: version.created_by_digest, created_at: version.created_at,
|
|
132
|
+
activated_by_digest: version.activated_by_digest, activated_at: version.activated_at)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The declaration a developer pastes into the model to make this lens
|
|
137
|
+
# part of the declared vocabulary (R43).
|
|
138
|
+
def declaration_snippet
|
|
139
|
+
questions.to_h.map do |label, question|
|
|
140
|
+
line = "label :#{label}, :#{question['type']}, question: #{question['instructions'].to_s.inspect}"
|
|
141
|
+
case question["type"]
|
|
142
|
+
when "noul" then question["criteria"] ? "#{line}, criteria: #{question['criteria'].inspect}" : line
|
|
143
|
+
when "choice" then "#{line}, options: #{question['criteria'].inspect}"
|
|
144
|
+
when "score" then "#{line}, legend: #{question['criteria'].inspect}"
|
|
145
|
+
else raise InvalidLens, "#{label}: unknown question type #{question['type'].inspect}"
|
|
146
|
+
end
|
|
147
|
+
end.join("\n")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def promote!(io: $stdout)
|
|
151
|
+
declaration_snippet.tap { |snippet| io.puts(snippet) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def described_model
|
|
157
|
+
model
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Lenses
|
|
3
|
+
# One lens question behind the LabelDefinition interface, so the labeler,
|
|
4
|
+
# request builder, label vectors, and query encoding treat it like a
|
|
5
|
+
# declared label. `key` is its storage key ("lens:<id>:<label>"); in Jev
|
|
6
|
+
# requests it is asked as "lens<id>__<label>", which cannot collide with a
|
|
7
|
+
# declared key because declared keys never hold a double underscore.
|
|
8
|
+
class LensLabel
|
|
9
|
+
attr_reader :lens_id, :label, :key, :type, :instructions
|
|
10
|
+
|
|
11
|
+
def initialize(lens_id, label, question)
|
|
12
|
+
@lens_id = lens_id
|
|
13
|
+
@label = label.to_s
|
|
14
|
+
@question = question.deep_dup.freeze
|
|
15
|
+
@key = Lenses.label_key(lens_id, @label)
|
|
16
|
+
@type = question.fetch("type").to_sym
|
|
17
|
+
@instructions = question["instructions"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def question_key
|
|
21
|
+
"#{KEY_PREFIX}#{lens_id}#{Questions::SEPARATOR}#{label}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def question(_tenant_key = nil)
|
|
25
|
+
@question.deep_dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def options(_tenant_key = nil)
|
|
29
|
+
@question["criteria"].to_h.transform_keys(&:to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def storage_keys(tenant_key = nil)
|
|
33
|
+
type == :choice ? options(tenant_key).keys.map { |option| "#{key}:#{option}" } : [ key ]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def per_tenant?
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def supplied?
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def description = instructions
|
|
45
|
+
|
|
46
|
+
def filter_at = nil
|
|
47
|
+
def boost = nil
|
|
48
|
+
def filter_weight = 0.0
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Lenses
|
|
3
|
+
# Who may create, regenerate, activate, and restore lenses (R42).
|
|
4
|
+
#
|
|
5
|
+
# `config.lenses.creators` bounds the scopes a lens may have:
|
|
6
|
+
# :developers - any scope, for users the host hook marks as developers or admins
|
|
7
|
+
# :tenant_users - tenant or personal lenses, for users of that tenant
|
|
8
|
+
# :each_user - personal lenses only, for their own creator
|
|
9
|
+
# The host hook `config.lenses.authorize_lens.call(user, scope)` always has
|
|
10
|
+
# the final word; without one, every change is refused.
|
|
11
|
+
module Policy
|
|
12
|
+
SCOPES = { developers: %i[app tenant user], tenant_users: %i[tenant user], each_user: %i[user] }.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def authorize!(user, scope, model: nil, settings: Lenses.settings)
|
|
17
|
+
allowed = SCOPES.fetch(settings.creators)
|
|
18
|
+
unless allowed.include?(scope.type)
|
|
19
|
+
raise NotAuthorized, "#{scope.type} lenses are not allowed when lens creators are #{settings.creators}"
|
|
20
|
+
end
|
|
21
|
+
if settings.creators == :each_user && scope.user_digest != digest_for(user, settings: settings)
|
|
22
|
+
raise NotAuthorized, "a personal lens can only be changed by its own user"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
hook = settings.authorize_lens
|
|
26
|
+
raise NotAuthorized, "configure config.lenses.authorize_lens to allow lens changes" unless hook
|
|
27
|
+
raise NotAuthorized, "not allowed to change #{scope.type} lenses#{" on #{model.name}" if model}" unless hook.call(user, scope)
|
|
28
|
+
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def allowed?(user, scope, **options)
|
|
33
|
+
authorize!(user, scope, **options)
|
|
34
|
+
rescue NotAuthorized
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def digest_for(user, settings: Lenses.settings)
|
|
39
|
+
return if user.nil?
|
|
40
|
+
|
|
41
|
+
key = settings.key_for(user)
|
|
42
|
+
Lenses.digest(key) if key.present?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Lenses
|
|
3
|
+
# Answers a draft's questions on a sample of recent in-scope records in
|
|
4
|
+
# one packed single-tenant Jev request, at encode priority and inside the
|
|
5
|
+
# lens's spend cap (R40). `compare` runs an old and a new version on the
|
|
6
|
+
# same sample and reports how the answers moved (R45).
|
|
7
|
+
#
|
|
8
|
+
# A preview holds record ids and numbers only; record text exists only in
|
|
9
|
+
# the request state while the call runs.
|
|
10
|
+
class Previewer
|
|
11
|
+
MAX_EXAMPLES = 5
|
|
12
|
+
|
|
13
|
+
Preview = Data.define(:tenant_key, :sample_ids, :types, :values, :buckets, :distribution, :means, :examples,
|
|
14
|
+
:estimate, :cost)
|
|
15
|
+
Estimate = Data.define(:records, :requests, :input_tokens, :cost_usd, :duration_seconds, :within_cap)
|
|
16
|
+
Comparison = Data.define(:before, :after, :shift, :changed_count, :changed_ids, :added, :removed)
|
|
17
|
+
|
|
18
|
+
def self.preview(target, **options)
|
|
19
|
+
new.preview(target, **options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.compare(draft, active, **options)
|
|
23
|
+
new.compare(draft, active, **options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :client, :budget, :config
|
|
27
|
+
|
|
28
|
+
def initialize(client: Truffler.config.client, budget: Budget.new, config: Truffler.config)
|
|
29
|
+
@client = client
|
|
30
|
+
@budget = budget
|
|
31
|
+
@config = config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# target: a Draft, a Version, or a Lens (its active version). relation
|
|
35
|
+
# narrows the sample to what the host's permission scope allows.
|
|
36
|
+
def preview(target, sample: config.lenses.sample_size, ids: nil, tenant_key: nil, relation: nil, by: nil)
|
|
37
|
+
draft = coerce(target)
|
|
38
|
+
Validation.validate!(draft.questions, reused: draft.reused)
|
|
39
|
+
scope = relation || draft.model.all
|
|
40
|
+
tenant_key = resolve_tenant(draft, scope, tenant_key)
|
|
41
|
+
records = sample_records(draft, scope, tenant_key, sample, ids)
|
|
42
|
+
return empty_preview(draft, scope, tenant_key, records) if records.empty? || draft.questions.empty?
|
|
43
|
+
|
|
44
|
+
state, questions, tags = build(draft, records)
|
|
45
|
+
request_tokens = Tokens.estimate({ state: state, questions: questions })
|
|
46
|
+
check_cap!(draft, config.cost_for(request_tokens))
|
|
47
|
+
decision = budget.acquire(priority: :encode, user_key: (config.lenses.key_for(by) if by))
|
|
48
|
+
raise BudgetExhausted, "no Jev budget for a lens preview" unless decision.granted?
|
|
49
|
+
|
|
50
|
+
answers = client.ask(state: state, questions: questions, priority: :encode)
|
|
51
|
+
cost = answers.usage&.cost.to_f
|
|
52
|
+
draft.lens&.record_spend!(cost)
|
|
53
|
+
summarize(draft, scope, tenant_key, tags, answers, request_tokens, cost)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def compare(draft, active, sample: config.lenses.sample_size, tenant_key: nil, relation: nil, by: nil)
|
|
57
|
+
before = preview(active, sample: sample, tenant_key: tenant_key, relation: relation, by: by)
|
|
58
|
+
after = preview(draft, ids: before.sample_ids, tenant_key: before.tenant_key, relation: relation, by: by)
|
|
59
|
+
shared = before.types.keys.select { |label| after.types[label] == before.types[label] }
|
|
60
|
+
common = before.sample_ids & after.sample_ids
|
|
61
|
+
changed = common.select { |id| shared.any? { |label| before.buckets.dig(id, label) != after.buckets.dig(id, label) } }
|
|
62
|
+
|
|
63
|
+
Comparison.new(before: before, after: after, shift: shared.index_with { |label| shift(before, after, label, common) },
|
|
64
|
+
changed_count: changed.size, changed_ids: changed, added: after.types.keys - shared, removed: before.types.keys - shared)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def coerce(target)
|
|
70
|
+
case target
|
|
71
|
+
when Draft then target
|
|
72
|
+
when Version then target.to_draft
|
|
73
|
+
when Lens then target.active_version&.to_draft || raise(InvalidLens, "lens #{target.id} has no active version")
|
|
74
|
+
else raise ArgumentError, "cannot preview #{target.class.name}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def definition(draft)
|
|
79
|
+
draft.model.truffler_definition
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_tenant(draft, scope, tenant_key)
|
|
83
|
+
tenant_key ||= draft.scope.tenant_key
|
|
84
|
+
return tenant_key&.to_s if tenant_key || !definition(draft).scoped?
|
|
85
|
+
|
|
86
|
+
scope.reorder(definition(draft).arrival_order).pick(definition(draft).tenant_column)&.to_s
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def in_tenant(draft, scope, tenant_key)
|
|
90
|
+
definition(draft).scoped? ? scope.where(definition(draft).tenant_column => tenant_key) : scope
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def sample_records(draft, scope, tenant_key, sample, ids)
|
|
94
|
+
model = draft.model
|
|
95
|
+
records = in_tenant(draft, scope, tenant_key)
|
|
96
|
+
records = records.where(model.primary_key => ids) if ids
|
|
97
|
+
per_request = [ config.max_questions_per_request / [ draft.questions.size, 1 ].max, 1 ].max
|
|
98
|
+
records.reorder(definition(draft).arrival_order).limit([ ids&.size || sample, per_request ].min).to_a
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build(draft, records)
|
|
102
|
+
state_records = {}
|
|
103
|
+
questions = {}
|
|
104
|
+
tags = {}
|
|
105
|
+
used = Tokens.estimate(Labeling::RequestBuilder::TASK)
|
|
106
|
+
records.each.with_index(1) do |record, index|
|
|
107
|
+
tag = Questions.tag("r", index)
|
|
108
|
+
fields = definition(draft).request_fields(record, max_chars: config.max_field_chars)
|
|
109
|
+
asked = draft.questions.to_h do |label, question|
|
|
110
|
+
[ Questions.tagged_id(tag, label), question.merge("instructions" => { "record" => tag, "question" => question["instructions"] }) ]
|
|
111
|
+
end
|
|
112
|
+
used += Tokens.estimate(fields) + Tokens.estimate(asked)
|
|
113
|
+
break if tags.any? && used > config.request_token_budget
|
|
114
|
+
|
|
115
|
+
state_records[tag] = fields
|
|
116
|
+
questions.merge!(asked)
|
|
117
|
+
tags[tag] = record.id
|
|
118
|
+
end
|
|
119
|
+
[ { "task" => Labeling::RequestBuilder::TASK, "records" => state_records }, questions, tags ]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def check_cap!(draft, estimate)
|
|
123
|
+
remaining = remaining_cap(draft)
|
|
124
|
+
return if remaining.nil? || estimate <= remaining
|
|
125
|
+
|
|
126
|
+
raise LensSpendCapExceeded, format("a lens preview needs about $%.6f but the lens has $%.6f left", estimate, remaining)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def remaining_cap(draft)
|
|
130
|
+
draft.lens ? draft.lens.remaining_spend : config.lenses.spend_cap_usd
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def summarize(draft, scope, tenant_key, tags, answers, request_tokens, cost)
|
|
134
|
+
types = draft.question_types
|
|
135
|
+
values = {}
|
|
136
|
+
buckets = {}
|
|
137
|
+
tags.each do |tag, record_id|
|
|
138
|
+
values[record_id] = {}
|
|
139
|
+
buckets[record_id] = {}
|
|
140
|
+
draft.questions.each do |label, question|
|
|
141
|
+
record_values, bucket = answer(label, question, answers, Questions.tagged_id(tag, label))
|
|
142
|
+
values[record_id].merge!(record_values)
|
|
143
|
+
buckets[record_id][label] = bucket
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
distribution = draft.questions.to_h do |label, question|
|
|
148
|
+
counts = bucket_names(question).index_with(0)
|
|
149
|
+
buckets.each_value { |record| counts[record[label]] = counts[record[label]].to_i + 1 }
|
|
150
|
+
[ label, counts ]
|
|
151
|
+
end
|
|
152
|
+
means = types.reject { |_, type| type == "choice" }.to_h do |label, _|
|
|
153
|
+
[ label, values.values.sum { |record| record[label] } / values.size ]
|
|
154
|
+
end
|
|
155
|
+
examples = draft.questions.keys.index_with do |label|
|
|
156
|
+
buckets.group_by { |_, record| record[label] }.transform_values { |rows| rows.first(MAX_EXAMPLES).map(&:first) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
Preview.new(tenant_key: tenant_key, sample_ids: tags.values, types: types, values: values, buckets: buckets,
|
|
160
|
+
distribution: distribution, means: means, examples: examples, cost: cost,
|
|
161
|
+
estimate: estimate(draft, scope, tenant_key, request_tokens / tags.size.to_f, cost))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Returns [{storage suffix => value}, bucket]. Storage suffixes match
|
|
165
|
+
# the label rows a lens writes: "label" or "label:option".
|
|
166
|
+
def answer(label, question, answers, id)
|
|
167
|
+
case question["type"]
|
|
168
|
+
when "noul"
|
|
169
|
+
value = answers.noul(id)
|
|
170
|
+
[ { label => value }, value >= 0.5 ? "yes" : "no" ]
|
|
171
|
+
when "score"
|
|
172
|
+
value = answers.score(id)
|
|
173
|
+
[ { label => value }, (value * (question["criteria"].size - 1)).round.to_s ]
|
|
174
|
+
when "choice"
|
|
175
|
+
options = question["criteria"].keys
|
|
176
|
+
[ options.to_h { |option| [ "#{label}:#{option}", answers.probability(id, option) ] }, answers.choice(id) ]
|
|
177
|
+
else raise InvalidLens, "#{label}: unknown question type #{question['type'].inspect}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def bucket_names(question)
|
|
182
|
+
case question["type"]
|
|
183
|
+
when "noul" then %w[yes no]
|
|
184
|
+
when "score" then question["criteria"].each_index.map(&:to_s)
|
|
185
|
+
when "choice" then question["criteria"].keys
|
|
186
|
+
else raise InvalidLens, "unknown question type #{question['type'].inspect}"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Backfill estimate for every record the lens applies to: records ×
|
|
191
|
+
# tokens per record × price, and requests at the backfill ceiling.
|
|
192
|
+
def estimate(draft, scope, tenant_key, tokens_per_record, preview_cost)
|
|
193
|
+
records = (draft.scope.app? ? scope : in_tenant(draft, scope, tenant_key)).count
|
|
194
|
+
tokens = (records * tokens_per_record).ceil
|
|
195
|
+
cost = config.cost_for(tokens)
|
|
196
|
+
requests = (records / config.batch_size.to_f).ceil
|
|
197
|
+
remaining = remaining_cap(draft)
|
|
198
|
+
remaining -= preview_cost if remaining && !draft.lens
|
|
199
|
+
Estimate.new(records: records, requests: requests, input_tokens: tokens, cost_usd: cost,
|
|
200
|
+
duration_seconds: (requests / budget.ceiling(:backfill)).ceil, within_cap: remaining.nil? || cost <= remaining)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def empty_preview(draft, scope, tenant_key, records)
|
|
204
|
+
Preview.new(tenant_key: tenant_key, sample_ids: records.map(&:id), types: draft.question_types, values: {}, buckets: {},
|
|
205
|
+
distribution: draft.questions.transform_values { |question| bucket_names(question).index_with(0) }, means: {},
|
|
206
|
+
examples: {}, cost: 0.0, estimate: estimate(draft, scope, tenant_key, 0, 0.0))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def shift(before, after, label, ids)
|
|
210
|
+
names = before.distribution[label].keys | after.distribution[label].keys
|
|
211
|
+
buckets = names.index_with { |name| after.distribution[label][name].to_i - before.distribution[label][name].to_i }
|
|
212
|
+
mean = after.means[label] - before.means[label] if before.means.key?(label) && after.means.key?(label)
|
|
213
|
+
{ buckets: buckets, mean: mean, changed: ids.count { |id| before.buckets.dig(id, label) != after.buckets.dig(id, label) } }
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Lenses
|
|
3
|
+
# Drafts lenses from query-miss clusters that passed the distinct-user
|
|
4
|
+
# gate (R28, R29, R42). Proposals are stored as `proposed` and change
|
|
5
|
+
# nothing until someone the policy allows activates them. The drafting
|
|
6
|
+
# model sees only aggregated cluster terms and counts.
|
|
7
|
+
class Proposer
|
|
8
|
+
def self.propose(model, **options)
|
|
9
|
+
new.propose(model, **options)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(drafter: Drafter.new, settings: Lenses.settings)
|
|
13
|
+
@drafter = drafter
|
|
14
|
+
@settings = settings
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# tenant_key: one tenant's misses become tenant proposals;
|
|
18
|
+
# Misses::ALL_TENANTS pools every tenant into app proposals.
|
|
19
|
+
def propose(model, tenant_key:)
|
|
20
|
+
return [] unless @settings.proposals
|
|
21
|
+
|
|
22
|
+
model = Misses.resolve(model)
|
|
23
|
+
scope = tenant_key == Misses::ALL_TENANTS ? Scope.app : Scope.tenant(tenant_key)
|
|
24
|
+
Misses.clusters(model, tenant_key: tenant_key).filter_map do |cluster|
|
|
25
|
+
proposal_digest = Canonical.digest(terms: cluster.terms.sort)
|
|
26
|
+
next if existing?(model, scope, proposal_digest)
|
|
27
|
+
|
|
28
|
+
draft = @drafter.draft(description_for(model, cluster), model: model, scope: scope, clusters: [ cluster ])
|
|
29
|
+
create(draft, proposal_digest)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def existing?(model, scope, proposal_digest)
|
|
36
|
+
Lens.for_model(model).where(scope_type: scope.type.to_s, tenant_key: scope.tenant_key, proposal_digest: proposal_digest).exists?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def description_for(model, cluster)
|
|
40
|
+
"#{model.model_name.human.downcase.pluralize} about #{cluster.terms.to_sentence}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def create(draft, proposal_digest)
|
|
44
|
+
Lens.transaction do
|
|
45
|
+
lens = Lens.build_from(draft, status: "proposed", origin: "proposal", proposal_digest: proposal_digest).tap(&:save!)
|
|
46
|
+
lens.add_version!(draft, by: nil)
|
|
47
|
+
Instrumentation.instrument(:lens_proposed, record_type: lens.record_type, tenant_key: lens.tenant_key, lens_id: lens.id)
|
|
48
|
+
lens
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "ruby_llm"
|
|
2
|
+
|
|
3
|
+
module Truffler
|
|
4
|
+
module Lenses
|
|
5
|
+
# The default drafting model: `RubyLLM.chat(...).with_schema(...)`, which
|
|
6
|
+
# ruby_llm 1.x and 2 share. 1.x parses structured output into `content`;
|
|
7
|
+
# 2 keeps `content` as text and parses it in `parsed`. ruby_llm is not a
|
|
8
|
+
# truffler dependency; this file loads only when the default is used.
|
|
9
|
+
class RubyLLMGenerator
|
|
10
|
+
def generate(prompt:, schema:, model: nil)
|
|
11
|
+
message = chat(**{ model: model }.compact).with_schema(schema).ask(prompt)
|
|
12
|
+
{ draft: parsed(message), model: model_of(message), input_tokens: input_tokens(message) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def chat(**options)
|
|
18
|
+
RubyLLM.chat(**options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parsed(message)
|
|
22
|
+
body = message.respond_to?(:parsed) ? message.parsed : message.content
|
|
23
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
24
|
+
raise InvalidLens, "the drafting model returned no structured draft" unless body.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
body
|
|
27
|
+
rescue JSON::ParserError
|
|
28
|
+
raise InvalidLens, "the drafting model returned malformed JSON"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def model_of(message)
|
|
32
|
+
(message.respond_to?(:model_id) && message.model_id) || (message.respond_to?(:model) && message.model) || nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def input_tokens(message)
|
|
36
|
+
return message.input_tokens if message.respond_to?(:input_tokens) && message.input_tokens
|
|
37
|
+
|
|
38
|
+
message.tokens&.input if message.respond_to?(:tokens)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Lenses
|
|
3
|
+
# What a lens applies to: the whole app, one tenant, or one user inside a
|
|
4
|
+
# tenant. User keys are held only as keyed digests.
|
|
5
|
+
Scope = Data.define(:type, :tenant_key, :user_digest) do
|
|
6
|
+
def self.app
|
|
7
|
+
new(type: :app, tenant_key: nil, user_digest: nil)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.tenant(tenant_key)
|
|
11
|
+
raise ArgumentError, "a tenant lens needs a tenant key" if tenant_key.blank?
|
|
12
|
+
|
|
13
|
+
new(type: :tenant, tenant_key: tenant_key.to_s, user_digest: nil)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.user(tenant_key, user_key)
|
|
17
|
+
raise ArgumentError, "a user lens needs a user key" if user_key.blank?
|
|
18
|
+
|
|
19
|
+
new(type: :user, tenant_key: tenant_key&.to_s, user_digest: Lenses.digest(user_key))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def app? = type == :app
|
|
23
|
+
def tenant? = type == :tenant
|
|
24
|
+
def user? = type == :user
|
|
25
|
+
|
|
26
|
+
def scope_key
|
|
27
|
+
case type
|
|
28
|
+
when :app then nil
|
|
29
|
+
when :tenant then tenant_key
|
|
30
|
+
when :user then user_digest
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Lenses
|
|
3
|
+
# Stores `description` through Lenses.seal, so encrypted models keep only
|
|
4
|
+
# ciphertext (or nothing) in the lens tables (R44).
|
|
5
|
+
module SealedDescription
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
before_save :seal_description
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def description
|
|
13
|
+
return @description_text if defined?(@description_text)
|
|
14
|
+
|
|
15
|
+
Lenses.unseal(described_model, read_attribute(:description))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def description=(text)
|
|
19
|
+
@description_text = text
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def seal_description
|
|
25
|
+
write_attribute(:description, Lenses.seal(described_model, @description_text)) if defined?(@description_text)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Lenses
|
|
3
|
+
# `config.lenses`: who may create lenses, whether the system proposes them
|
|
4
|
+
# from query misses, and the limits every lens runs under (R42, R43).
|
|
5
|
+
#
|
|
6
|
+
# config.lenses.creators = :tenant_users
|
|
7
|
+
# config.lenses.authorize_lens = ->(user, scope) { user.admin? || scope.tenant_key == user.account_id.to_s }
|
|
8
|
+
class Settings
|
|
9
|
+
CREATORS = %i[developers tenant_users each_user].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :creators
|
|
12
|
+
attr_accessor :proposals, :expire_after, :spend_cap_usd, :sample_size, :max_questions, :generator,
|
|
13
|
+
:drafter_model, :authorize_lens, :user_key
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@creators = :developers
|
|
17
|
+
@proposals = false
|
|
18
|
+
@expire_after = 30.days
|
|
19
|
+
@spend_cap_usd = 1.0
|
|
20
|
+
@sample_size = 20
|
|
21
|
+
@max_questions = 8
|
|
22
|
+
@generator = nil
|
|
23
|
+
@drafter_model = nil
|
|
24
|
+
@authorize_lens = nil
|
|
25
|
+
@user_key = ->(user) { user.is_a?(ActiveRecord::Base) ? Search::Keystroke.user_key(user) : (user.respond_to?(:id) ? user.id : user) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def creators=(value)
|
|
29
|
+
value = value.to_sym
|
|
30
|
+
raise ArgumentError, "config.lenses.creators must be one of #{CREATORS.join(', ')}" unless CREATORS.include?(value)
|
|
31
|
+
|
|
32
|
+
@creators = value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def generator_or_default
|
|
36
|
+
generator || RubyLLMGenerator.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def key_for(user)
|
|
40
|
+
key = user_key.call(user)
|
|
41
|
+
key.nil? ? nil : key.to_s
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|