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,45 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Truffler
|
|
4
|
+
module Embeddings
|
|
5
|
+
# Deterministic vectors for tests and benchmarks: each token adds a signed
|
|
6
|
+
# unit at a position chosen by its hash, and the sum is normalized, so
|
|
7
|
+
# texts that share words point the same way.
|
|
8
|
+
class FakeEmbedder < Embedder
|
|
9
|
+
attr_reader :calls
|
|
10
|
+
|
|
11
|
+
def initialize(model: nil)
|
|
12
|
+
@model = model
|
|
13
|
+
@error = nil
|
|
14
|
+
@calls = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fail_with(error)
|
|
18
|
+
@error = error
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def recover!
|
|
23
|
+
@error = nil
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def perform(texts, model:, dimensions:)
|
|
28
|
+
@calls << { texts: texts, model: model, dimensions: dimensions }
|
|
29
|
+
raise @error if @error
|
|
30
|
+
|
|
31
|
+
{ vectors: texts.map { |text| vector_for(text, dimensions) }, model: @model || model }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def vector_for(text, dimensions)
|
|
35
|
+
vector = Array.new(dimensions, 0.0)
|
|
36
|
+
text.to_s.downcase.scan(/[[:alnum:]]+/).each do |token|
|
|
37
|
+
digest = Digest::SHA256.digest(token)
|
|
38
|
+
vector[digest.unpack1("N") % dimensions] += digest.getbyte(4).even? ? 1.0 : -1.0
|
|
39
|
+
end
|
|
40
|
+
norm = Math.sqrt(vector.sum { |value| value * value })
|
|
41
|
+
norm.zero? ? vector : vector.map { |value| value / norm }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Embeddings
|
|
3
|
+
# A record's labels as a named-dimension vector (KTD20): one float per
|
|
4
|
+
# storage key in sorted order, choice options expanded, stored in
|
|
5
|
+
# truffler_embeddings beside the optional text vector and tagged with the
|
|
6
|
+
# vocabulary version that fixed the key order. Unlabeled keys are 0.0.
|
|
7
|
+
# Vectors are built from truffler_labels only, so a vocabulary change
|
|
8
|
+
# rebuilds them without asking Jev. Lens labels in the record's labeling
|
|
9
|
+
# scope (KTD21) extend the vector after the declared ones.
|
|
10
|
+
class LabelVector
|
|
11
|
+
attr_reader :model
|
|
12
|
+
|
|
13
|
+
def initialize(model)
|
|
14
|
+
@model = model
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def keys(tenant_key = nil)
|
|
18
|
+
vocabulary.labels_for(tenant_key: tenant_key, all_users: true).values.flat_map { |label| label.storage_keys(tenant_key) }.sort
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def write(record_ids, tenant_key:)
|
|
22
|
+
return 0 if record_ids.empty?
|
|
23
|
+
|
|
24
|
+
keys = keys(tenant_key)
|
|
25
|
+
version = version_for(tenant_key)
|
|
26
|
+
values = Records::Label.where(record_type: record_type, record_id: record_ids, label_key: keys)
|
|
27
|
+
.pluck(:record_id, :label_key, :value)
|
|
28
|
+
.each_with_object(Hash.new { |hash, id| hash[id] = {} }) { |(id, key, value), map| map[id.to_s][key] = value }
|
|
29
|
+
now = Time.current
|
|
30
|
+
rows = record_ids.uniq.map do |id|
|
|
31
|
+
vector = keys.map { |key| values[id.to_s].fetch(key, 0.0) }
|
|
32
|
+
{ record_type: record_type, record_id: id, tenant_key: tenant_key, label_vector: Records::Embedding.pack(vector),
|
|
33
|
+
label_vocabulary_version: version, created_at: now, updated_at: now }
|
|
34
|
+
end
|
|
35
|
+
Records::Embedding.upsert_all(rows, unique_by: %i[record_type record_id],
|
|
36
|
+
update_only: %i[tenant_key label_vector label_vocabulary_version updated_at])
|
|
37
|
+
rows.size
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Rewrites every vector whose vocabulary version is not current, for
|
|
41
|
+
# one tenant or all of them. Returns the number of records rewritten.
|
|
42
|
+
def rebuild(tenant_key: :all, batch_size: 500)
|
|
43
|
+
tenants = tenant_key == :all ? Records::Label.where(record_type: record_type).distinct.pluck(:tenant_key) : [ tenant_key ]
|
|
44
|
+
tenants.sum do |tenant|
|
|
45
|
+
version = version_for(tenant)
|
|
46
|
+
current = Records::Embedding.for_model(model).where(tenant_key: tenant, label_vocabulary_version: version).select(:record_id)
|
|
47
|
+
ids = Records::Label.where(record_type: record_type, tenant_key: tenant).where.not(record_id: current).distinct.pluck(:record_id)
|
|
48
|
+
ids.each_slice(batch_size).sum { |slice| write(slice, tenant_key: tenant) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The stored vector keyed by label, or nil when it is missing or was
|
|
53
|
+
# written under another vocabulary version.
|
|
54
|
+
def read(record)
|
|
55
|
+
tenant_key = definition.tenant_key_for(record)
|
|
56
|
+
row = Records::Embedding.for_model(model).find_by(record_id: record.id)
|
|
57
|
+
return unless row&.label_vector && row.label_vocabulary_version == version_for(tenant_key)
|
|
58
|
+
|
|
59
|
+
keys(tenant_key).zip(row.label_values).to_h
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def definition
|
|
65
|
+
model.truffler_definition
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def vocabulary
|
|
69
|
+
definition.vocabulary
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def version_for(tenant_key)
|
|
73
|
+
vocabulary.version(tenant_key: tenant_key, all_users: true)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def record_type
|
|
77
|
+
model.polymorphic_name
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Embeddings
|
|
3
|
+
# Cosine distance computed by the database: pgvector's `<=>` on a vector
|
|
4
|
+
# column, or sqlite-vec's `vec_distance_cosine` on float32 blobs (the
|
|
5
|
+
# host loads the extension). Because similarity is plain SQL, search can
|
|
6
|
+
# score text inline in its one query as an exact scan of the tenant's
|
|
7
|
+
# rows; an approximate (HNSW) index is an opt-in host migration.
|
|
8
|
+
class NeighborStore < VectorStore
|
|
9
|
+
TABLE = "truffler_embeddings".freeze
|
|
10
|
+
|
|
11
|
+
def self.available?(connection, table: TABLE, column: "embedding")
|
|
12
|
+
!dialect(connection, table: table, column: column).nil?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.dialect(connection, table: TABLE, column: "embedding")
|
|
16
|
+
case connection.adapter_name
|
|
17
|
+
when /postg/i
|
|
18
|
+
:postgres if connection.columns(table).find { |item| item.name == column.to_s }&.sql_type.to_s.start_with?("vector")
|
|
19
|
+
when /sqlite/i
|
|
20
|
+
:sqlite if sqlite_vec?(connection)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.sqlite_vec?(connection)
|
|
25
|
+
connection.select_value("SELECT vec_version()").present?
|
|
26
|
+
rescue ActiveRecord::StatementInvalid
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.distance_sql(dialect, connection, column_sql, vector)
|
|
31
|
+
case dialect
|
|
32
|
+
when :sqlite then "vec_distance_cosine(#{column_sql}, X'#{Records::Embedding.pack(vector).unpack1('H*')}')"
|
|
33
|
+
when :postgres then "(#{column_sql} <=> #{connection.quote("[#{vector.map(&:to_f).join(',')}]")}::vector)"
|
|
34
|
+
else raise Error, "the neighbor vector store needs pgvector or the sqlite-vec extension"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(dialect: nil)
|
|
39
|
+
@dialect = dialect
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def nearest(model, tenant_key:, vector:, k: DEFAULT_K)
|
|
43
|
+
distance = distance_sql(model, "#{TABLE}.embedding", vector)
|
|
44
|
+
embeddings(model, tenant_key, vector.size).order(Arel.sql(distance)).limit(k)
|
|
45
|
+
.pluck(:record_id, Arel.sql("1 - #{distance}")).map { |id, similarity| [ id, similarity.to_f ] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def similarity_sql(model, tenant_key:, vector:, k: nil)
|
|
49
|
+
similarity = "1 - #{distance_sql(model, "#{TABLE}.embedding", vector)}"
|
|
50
|
+
subquery = embeddings(model, tenant_key, vector.size).where("#{TABLE}.record_id = #{primary_key_sql(model)}")
|
|
51
|
+
Arel.sql("COALESCE((#{subquery.select(Arel.sql(similarity)).to_sql}), 0.0)")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def inline_sql?(_model)
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def distance_sql(model, column_sql, vector)
|
|
61
|
+
@dialect ||= self.class.dialect(model.connection)
|
|
62
|
+
self.class.distance_sql(@dialect, model.connection, column_sql, vector)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "ruby_llm"
|
|
2
|
+
|
|
3
|
+
module Truffler
|
|
4
|
+
module Embeddings
|
|
5
|
+
# The default embedder: `RubyLLM.embed`, which ruby_llm 1.x and 2 share.
|
|
6
|
+
# ruby_llm is not a truffler dependency; this file loads only when the
|
|
7
|
+
# default embedder is used.
|
|
8
|
+
class RubyLLMEmbedder < Embedder
|
|
9
|
+
def perform(texts, model:, dimensions:)
|
|
10
|
+
embedding = RubyLLM.embed(texts, model: model, dimensions: dimensions)
|
|
11
|
+
vectors = embedding.vectors
|
|
12
|
+
vectors = [ vectors ] if vectors.first.is_a?(Numeric)
|
|
13
|
+
{ vectors: vectors, model: embedding.model, input_tokens: input_tokens(embedding) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def input_tokens(embedding)
|
|
19
|
+
return embedding.input_tokens if embedding.respond_to?(:input_tokens)
|
|
20
|
+
|
|
21
|
+
embedding.tokens&.input if embedding.respond_to?(:tokens)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Embeddings
|
|
3
|
+
# Packed float blobs in truffler_embeddings with exact cosine computed in
|
|
4
|
+
# Ruby over the tenant's rows. For tests and small sets.
|
|
5
|
+
class RubyStore < VectorStore
|
|
6
|
+
def nearest(model, tenant_key:, vector:, k: DEFAULT_K)
|
|
7
|
+
rows = embeddings(model, tenant_key, vector.size).pluck(:record_id, :embedding)
|
|
8
|
+
rows.map { |id, blob| [ id, self.class.cosine(vector, Records::Embedding.unpack(blob)) ] }.max_by(k) { |_, similarity| similarity }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Embeddings
|
|
3
|
+
# Nearest-neighbor search over one tenant's vectors. `nearest` returns
|
|
4
|
+
# `[record_id, cosine_similarity]` pairs, most similar first.
|
|
5
|
+
# `similarity_sql` returns a scalar SQL expression over the model's table
|
|
6
|
+
# that search can put in its SELECT or ORDER BY: stores that compute
|
|
7
|
+
# similarity in the database (`inline_sql?`) scan every row exactly, and
|
|
8
|
+
# the rest fall back to a CASE over the top-K neighbors.
|
|
9
|
+
class VectorStore
|
|
10
|
+
ADAPTERS = %i[auto ruby neighbor].freeze
|
|
11
|
+
DEFAULT_K = 200
|
|
12
|
+
|
|
13
|
+
def self.for(model, config: Truffler.config)
|
|
14
|
+
embeddings = model.truffler_definition.embeddings
|
|
15
|
+
return unless embeddings
|
|
16
|
+
return ColumnStore.new(embeddings[:column]) if embeddings.key?(:column)
|
|
17
|
+
|
|
18
|
+
case config.vector_store&.to_sym
|
|
19
|
+
when :ruby then RubyStore.new
|
|
20
|
+
when :neighbor then NeighborStore.new
|
|
21
|
+
when :auto then NeighborStore.available?(model.connection) ? NeighborStore.new : RubyStore.new
|
|
22
|
+
else raise Error, "config.vector_store must be one of #{ADAPTERS.join(', ')}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.cosine(left, right)
|
|
27
|
+
dot = left_norm = right_norm = 0.0
|
|
28
|
+
left.each_with_index do |value, index|
|
|
29
|
+
other = right[index]
|
|
30
|
+
dot += value * other
|
|
31
|
+
left_norm += value * value
|
|
32
|
+
right_norm += other * other
|
|
33
|
+
end
|
|
34
|
+
left_norm.zero? || right_norm.zero? ? 0.0 : dot / Math.sqrt(left_norm * right_norm)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def nearest(model, tenant_key:, vector:, k: DEFAULT_K)
|
|
38
|
+
raise NotImplementedError, "#{self.class.name}#nearest"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def similarity_sql(model, tenant_key:, vector:, k: DEFAULT_K)
|
|
42
|
+
pairs = nearest(model, tenant_key: tenant_key, vector: vector, k: k)
|
|
43
|
+
return Arel.sql("0.0") if pairs.empty?
|
|
44
|
+
|
|
45
|
+
connection = model.connection
|
|
46
|
+
whens = pairs.map { |id, similarity| "WHEN #{connection.quote(id)} THEN #{Float(similarity)}" }
|
|
47
|
+
Arel.sql("(CASE #{primary_key_sql(model)} #{whens.join(' ')} ELSE 0.0 END)")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def inline_sql?(_model)
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def write(model, record, vector, fingerprint:)
|
|
55
|
+
now = Time.current
|
|
56
|
+
Records::Embedding.upsert(
|
|
57
|
+
{ record_type: model.polymorphic_name, record_id: record.id, tenant_key: model.truffler_definition.tenant_key_for(record),
|
|
58
|
+
fingerprint: fingerprint, embedding: Records::Embedding.encode(vector), dimensions: vector.size,
|
|
59
|
+
created_at: now, updated_at: now },
|
|
60
|
+
unique_by: %i[record_type record_id],
|
|
61
|
+
update_only: %i[tenant_key fingerprint embedding dimensions updated_at]
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def embeddings(model, tenant_key, dimensions)
|
|
68
|
+
check_tenant!(model, tenant_key)
|
|
69
|
+
Records::Embedding.for_model(model).with_vector.where(tenant_key: tenant_key&.to_s, dimensions: dimensions)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def check_tenant!(model, tenant_key)
|
|
73
|
+
return unless model.truffler_definition.scoped? && tenant_key.nil?
|
|
74
|
+
|
|
75
|
+
raise MissingScope, "#{model.name}: vector search needs a tenant"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def primary_key_sql(model)
|
|
79
|
+
connection = model.connection
|
|
80
|
+
"#{connection.quote_table_name(model.table_name)}.#{connection.quote_column_name(model.primary_key)}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# Optional text embeddings (R10, R11) and the label vectors of KTD20. The
|
|
3
|
+
# embedding text is built from the declared fields inside the embed step
|
|
4
|
+
# only and is never stored.
|
|
5
|
+
module Embeddings
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def embedder
|
|
9
|
+
Truffler.config.embedder || RubyLLMEmbedder.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Gem-managed embeddings, as opposed to a host column the gem only reads.
|
|
13
|
+
def managed?(definition)
|
|
14
|
+
definition.embeddings.present? && !definition.embeddings.key?(:column)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Changes when the embedding model, the width, or the embedded fields
|
|
18
|
+
# change, which marks every stored vector stale.
|
|
19
|
+
def fingerprint(definition)
|
|
20
|
+
settings = definition.embeddings
|
|
21
|
+
Canonical.digest(model: settings[:model], dimensions: settings[:dimensions], fields: definition.fields)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def text_for(definition, record, max_chars: Truffler.config.max_field_chars)
|
|
25
|
+
definition.field_values(record).filter_map do |field, value|
|
|
26
|
+
"#{field}: #{value.to_s.truncate(max_chars, omission: '')}" if value.present?
|
|
27
|
+
end.join("\n")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
class DefinitionError < Error; end
|
|
5
|
+
class SuppliedLabelFailed < Error; end
|
|
6
|
+
class MissingScope < Error; end
|
|
7
|
+
class LiveCallInTest < Error; end
|
|
8
|
+
class IncompleteAnswers < Error; end
|
|
9
|
+
class CassetteMiss < Error; end
|
|
10
|
+
class BudgetExhausted < Error; end
|
|
11
|
+
class TenantMismatch < Error; end
|
|
12
|
+
class NotAuthorized < Error; end
|
|
13
|
+
class InvalidLens < Error; end
|
|
14
|
+
class LensSpendCapExceeded < Error; end
|
|
15
|
+
class InvalidSuppliedAnswer < Error; end
|
|
16
|
+
|
|
17
|
+
# Carries the HTTP status and the original error's class name only, never a
|
|
18
|
+
# response body, which can echo record text back.
|
|
19
|
+
class ClientError < Error
|
|
20
|
+
attr_reader :status, :error_class
|
|
21
|
+
|
|
22
|
+
def self.from(error)
|
|
23
|
+
new(status: status_of(error), error_class: error.class.name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.status_of(error)
|
|
27
|
+
status = error.status if error.respond_to?(:status)
|
|
28
|
+
response = error.response if error.respond_to?(:response)
|
|
29
|
+
status ||= response.status if response.respond_to?(:status)
|
|
30
|
+
status ||= response[:status] if response.is_a?(Hash)
|
|
31
|
+
status&.to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(status: nil, error_class: nil)
|
|
35
|
+
@status = status
|
|
36
|
+
@error_class = error_class
|
|
37
|
+
super([ "Jev request failed", status && "(status #{status})", error_class ].compact.join(" "))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Instrumentation
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def instrument(event, payload = {})
|
|
6
|
+
ActiveSupport::Notifications.instrument("truffler.#{event}", Redaction.safe(payload))
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def monotonic_ms
|
|
10
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def elapsed_ms(started)
|
|
14
|
+
(monotonic_ms - started).round(2)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Jobs
|
|
3
|
+
# Backfills one model's stale, missing, failed, and demoted labels at
|
|
4
|
+
# backfill priority. Arguments are the record type, the id cursor, the
|
|
5
|
+
# spend so far, the cap, and the retry attempt, never record text. A
|
|
6
|
+
# budget denial or a page limit reschedules the job from its cursor; the
|
|
7
|
+
# spend cap ends it. A Jev error reschedules it with backoff and the spend
|
|
8
|
+
# already made, so retries cannot push past the cap; after MAX_ATTEMPTS
|
|
9
|
+
# the released rows wait for the ResumeJob sweep.
|
|
10
|
+
class BackfillJob < ActiveJob::Base
|
|
11
|
+
MAX_PAGES = 20
|
|
12
|
+
MAX_ATTEMPTS = 10
|
|
13
|
+
BUDGET_WAIT = 30.seconds
|
|
14
|
+
|
|
15
|
+
queue_as { Truffler.config.queue_name }
|
|
16
|
+
|
|
17
|
+
def perform(record_type, cursor: nil, spent: 0.0, spend_cap: Truffler.config.backfill_spend_cap, max_pages: MAX_PAGES,
|
|
18
|
+
attempt: 0)
|
|
19
|
+
model = record_type.safe_constantize
|
|
20
|
+
return unless model.respond_to?(:truffler_definition) && model.truffler_definition
|
|
21
|
+
|
|
22
|
+
result = Labeling::Backfill.new(model, cursor: cursor, spent: spent, spend_cap: spend_cap).run(max_pages: max_pages)
|
|
23
|
+
Instrumentation.instrument(:backfill, record_type: record_type, outcome: result.status,
|
|
24
|
+
labeled_count: result.labeled, request_count: result.requests, cost: result.cost)
|
|
25
|
+
|
|
26
|
+
follow_up = { cursor: result.cursor, spent: spent + result.cost, spend_cap: spend_cap, max_pages: max_pages }
|
|
27
|
+
case result.status
|
|
28
|
+
when :budget_denied then self.class.set(wait: BUDGET_WAIT).perform_later(record_type, **follow_up)
|
|
29
|
+
when :paused then self.class.perform_later(record_type, **follow_up)
|
|
30
|
+
when :client_error then retry_later(record_type, follow_up, attempt + 1)
|
|
31
|
+
when :complete, :spend_cap_reached then nil
|
|
32
|
+
else raise ArgumentError, "unknown backfill status #{result.status.inspect}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def retry_later(record_type, follow_up, attempt)
|
|
39
|
+
return if attempt >= MAX_ATTEMPTS
|
|
40
|
+
|
|
41
|
+
self.class.set(wait: (attempt**4) + 2).perform_later(record_type, **follow_up, attempt: attempt)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Jobs
|
|
3
|
+
# Embeds one record's declared fields and stores the vector. Arguments are
|
|
4
|
+
# the record type and id only. On an embedder failure nothing is written,
|
|
5
|
+
# `embedded_at` stays as it was, and the job retries; labels are untouched.
|
|
6
|
+
class EmbedJob < ActiveJob::Base
|
|
7
|
+
queue_as { Truffler.config.queue_name }
|
|
8
|
+
|
|
9
|
+
retry_on ClientError, IncompleteAnswers, wait: :polynomially_longer, attempts: 10 do
|
|
10
|
+
nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def perform(record_type, record_id)
|
|
14
|
+
model = record_type.safe_constantize
|
|
15
|
+
definition = model.try(:truffler_definition)
|
|
16
|
+
return unless definition && Embeddings.managed?(definition)
|
|
17
|
+
|
|
18
|
+
record = model.find_by(model.primary_key => record_id)
|
|
19
|
+
return Records::Embedding.where(record_type: record_type, record_id: record_id).delete_all unless record
|
|
20
|
+
|
|
21
|
+
settings = definition.embeddings
|
|
22
|
+
fingerprint = Embeddings.fingerprint(definition)
|
|
23
|
+
text = Embeddings.text_for(definition, record)
|
|
24
|
+
vector = Embeddings.embedder.embed([ text ], model: settings[:model], dimensions: settings[:dimensions]).vectors.first
|
|
25
|
+
Embeddings::VectorStore.for(model).write(model, record, vector, fingerprint: fingerprint)
|
|
26
|
+
|
|
27
|
+
now = Time.current
|
|
28
|
+
Records::RecordState.where(record_type: record_type, record_id: record.id)
|
|
29
|
+
.update_all(embedding_fingerprint: fingerprint, embedded_at: now, updated_at: now)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Jobs
|
|
3
|
+
# Encodes one pending query at encode priority. The only argument is the
|
|
4
|
+
# cache key; the query itself waits in the cache (encrypted on encrypted
|
|
5
|
+
# models). A Jev failure is dropped: the in-flight marker is released,
|
|
6
|
+
# so the next keystroke of that query prefetches again.
|
|
7
|
+
class EncodeQueryJob < ActiveJob::Base
|
|
8
|
+
queue_as { Truffler.config.queue_name }
|
|
9
|
+
|
|
10
|
+
discard_on ClientError, IncompleteAnswers
|
|
11
|
+
|
|
12
|
+
def perform(cache_key)
|
|
13
|
+
QueryEncoding::Encoder.new.encode(cache_key)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Jobs
|
|
3
|
+
# Expires lenses unused for `config.lenses.expire_after` (R43). Takes no
|
|
4
|
+
# arguments; hosts schedule it, for example daily.
|
|
5
|
+
class ExpireLensesJob < ActiveJob::Base
|
|
6
|
+
queue_as { Truffler.config.queue_name }
|
|
7
|
+
|
|
8
|
+
def perform
|
|
9
|
+
expired = Lenses::Lens.expire_unused!
|
|
10
|
+
Instrumentation.instrument("lens_expire", expired_count: expired)
|
|
11
|
+
expired
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Jobs
|
|
3
|
+
# Labels one tenant's pending live records. Arguments are the record type
|
|
4
|
+
# and tenant key only. On a Jev or budget failure the claimed rows go back
|
|
5
|
+
# to pending (or failed after max_attempts) and the job retries. Rows over
|
|
6
|
+
# the tenant's live cap drop to backfill priority and a delayed backfill
|
|
7
|
+
# is scheduled for them.
|
|
8
|
+
class LabelFlushJob < ActiveJob::Base
|
|
9
|
+
RETRYABLE = [ ClientError, BudgetExhausted, IncompleteAnswers ].freeze
|
|
10
|
+
|
|
11
|
+
queue_as { Truffler.config.queue_name }
|
|
12
|
+
|
|
13
|
+
retry_on(*RETRYABLE, wait: :polynomially_longer, attempts: 10) { nil }
|
|
14
|
+
|
|
15
|
+
def perform(record_type, tenant_key)
|
|
16
|
+
model = record_type.safe_constantize
|
|
17
|
+
return unless model.respond_to?(:truffler_definition) && model.truffler_definition
|
|
18
|
+
|
|
19
|
+
queue = Labeling::Queue.new(model)
|
|
20
|
+
queue.clear_marker(tenant_key)
|
|
21
|
+
states = queue.claim(tenant_key, priority: :live, limit: Truffler.config.batch_size)
|
|
22
|
+
return if states.empty?
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
result = Labeling::Labeler.new(model).label(states, priority: :live)
|
|
26
|
+
rescue *RETRYABLE => error
|
|
27
|
+
queue.release(states, error)
|
|
28
|
+
raise
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if result.demoted
|
|
32
|
+
queue.demote(states)
|
|
33
|
+
queue.schedule_backfill
|
|
34
|
+
end
|
|
35
|
+
queue.schedule(tenant_key) if queue.pending?(tenant_key, priority: :live)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Jobs
|
|
3
|
+
# Backfills one lens's scope after it is activated or restored (R41,
|
|
4
|
+
# R45). Its only argument is the lens id. A budget denial or the batch
|
|
5
|
+
# limit reschedules it; the spend cap, expiry, or completion ends it.
|
|
6
|
+
class LensBackfillJob < ActiveJob::Base
|
|
7
|
+
RETRYABLE = [ ClientError, IncompleteAnswers ].freeze
|
|
8
|
+
MAX_BATCHES = 20
|
|
9
|
+
BUDGET_WAIT = 30.seconds
|
|
10
|
+
|
|
11
|
+
queue_as { Truffler.config.queue_name }
|
|
12
|
+
|
|
13
|
+
retry_on(*RETRYABLE, wait: :polynomially_longer, attempts: 10) { nil }
|
|
14
|
+
|
|
15
|
+
def perform(lens_id, max_batches: MAX_BATCHES)
|
|
16
|
+
lens = Lenses::Lens.find_by(id: lens_id)
|
|
17
|
+
return unless lens&.active?
|
|
18
|
+
|
|
19
|
+
result = Lenses::Backfill.new(lens, max_batches: max_batches).run
|
|
20
|
+
Instrumentation.instrument(:lens_backfill, record_type: lens.record_type, tenant_key: lens.tenant_key, lens_id: lens.id,
|
|
21
|
+
outcome: result.status, labeled_count: result.labeled, request_count: result.requests, cost: result.spent_usd)
|
|
22
|
+
|
|
23
|
+
case result.status
|
|
24
|
+
when :budget_denied then self.class.set(wait: BUDGET_WAIT).perform_later(lens_id, max_batches: max_batches)
|
|
25
|
+
when :paused then self.class.perform_later(lens_id, max_batches: max_batches)
|
|
26
|
+
when :complete, :spend_cap_reached, :inactive then nil
|
|
27
|
+
else raise ArgumentError, "unknown lens backfill status #{result.status.inspect}"
|
|
28
|
+
end
|
|
29
|
+
result
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Jobs
|
|
3
|
+
# Runs the provider backup search for one Smart run (R19). Arguments are
|
|
4
|
+
# the run id plus the searching tenant and user keys; the query text is
|
|
5
|
+
# read from the run, never carried in the job. See Truffler::Providers for
|
|
6
|
+
# the run interface. An expired run is a no-op.
|
|
7
|
+
class ProviderSearchJob < ActiveJob::Base
|
|
8
|
+
queue_as { Truffler.config.queue_name }
|
|
9
|
+
|
|
10
|
+
def perform(run_id, tenant_key, user_key)
|
|
11
|
+
run = Providers.find_run(run_id)
|
|
12
|
+
return unless run
|
|
13
|
+
|
|
14
|
+
Providers::Runner.new(run, tenant_key: tenant_key, user_key: user_key).call
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Jobs
|
|
3
|
+
# Enforces the miss log retention window (R29). Takes no arguments; hosts
|
|
4
|
+
# schedule it, for example daily.
|
|
5
|
+
class PruneQueryMissesJob < ActiveJob::Base
|
|
6
|
+
queue_as { Truffler.config.queue_name }
|
|
7
|
+
|
|
8
|
+
def perform
|
|
9
|
+
deleted = Records::QueryMiss.expired.in_batches(of: 1_000).delete_all
|
|
10
|
+
Instrumentation.instrument("miss_prune", deleted_count: deleted)
|
|
11
|
+
deleted
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Jobs
|
|
3
|
+
# Reranks one chunk of a Smart run and pings the searcher. Arguments are
|
|
4
|
+
# the run id and the chunk index only. A Jev failure marks the chunk
|
|
5
|
+
# failed so its buckets still resolve; it is not retried.
|
|
6
|
+
class RerankChunkJob < ActiveJob::Base
|
|
7
|
+
queue_as { Truffler.config.queue_name }
|
|
8
|
+
|
|
9
|
+
def perform(run_id, index)
|
|
10
|
+
SmartSearch::Reranker.new.call(SmartSearch::Run.load(run_id), Integer(index))
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|