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,61 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Jobs
|
|
3
|
+
# A periodic sweep hosts schedule (every few minutes) so labeling resumes
|
|
4
|
+
# after a Jev outage or a crashed worker. It returns failed rows and rows
|
|
5
|
+
# stuck in labeling to pending, reschedules a flush for tenants whose live
|
|
6
|
+
# rows have waited past `resume_pending_after`, and starts a backfill for
|
|
7
|
+
# rows waiting at backfill priority. For models with gem-managed
|
|
8
|
+
# embeddings it also enqueues up to `embedding_sweep_limit` missing or
|
|
9
|
+
# stale embeddings, at most once per `embedding_sweep_interval`, so an
|
|
10
|
+
# embedder outage cannot pile duplicate jobs onto the queue. Pass a record
|
|
11
|
+
# type to sweep one model.
|
|
12
|
+
class ResumeJob < ActiveJob::Base
|
|
13
|
+
queue_as { Truffler.config.queue_name }
|
|
14
|
+
|
|
15
|
+
class_attribute :embedding_sweep_limit, default: 1_000
|
|
16
|
+
class_attribute :embedding_sweep_interval, default: 1.hour
|
|
17
|
+
|
|
18
|
+
def perform(record_type = nil)
|
|
19
|
+
models = record_type ? [ record_type.safe_constantize ].compact : Truffler.registry.models
|
|
20
|
+
models.select { |model| model.respond_to?(:truffler_definition) && model.truffler_definition }.each { |model| sweep(model) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def sweep(model)
|
|
26
|
+
states = Records::RecordState.for_model(model)
|
|
27
|
+
cutoff = Time.current - Truffler.config.resume_pending_after
|
|
28
|
+
requeued = requeue(states.where(status: "failed").or(states.where(status: "labeling").where(claimed_at: ...cutoff)))
|
|
29
|
+
waiting = states.where(status: "pending").where(updated_at: ...cutoff)
|
|
30
|
+
|
|
31
|
+
live = (requeued.select { |_, priority| priority == "live" }.map(&:first) +
|
|
32
|
+
waiting.where(priority: "live").distinct.pluck(:tenant_key)).uniq
|
|
33
|
+
queue = Labeling::Queue.new(model)
|
|
34
|
+
live.each do |tenant_key|
|
|
35
|
+
queue.clear_marker(tenant_key)
|
|
36
|
+
queue.schedule(tenant_key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
backfill = requeued.any? { |_, priority| priority == "backfill" } || waiting.exists?(priority: "backfill")
|
|
40
|
+
BackfillJob.perform_later(model.polymorphic_name) if backfill
|
|
41
|
+
sweep_embeddings(model)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def sweep_embeddings(model)
|
|
45
|
+
return unless Embeddings.managed?(model.truffler_definition)
|
|
46
|
+
|
|
47
|
+
marker = "truffler:embedding_sweep:#{model.polymorphic_name}"
|
|
48
|
+
return unless Truffler.config.cache_store.write(marker, true, unless_exist: true, expires_in: embedding_sweep_interval)
|
|
49
|
+
|
|
50
|
+
Embeddings::Backfill.new(model).enqueue(limit: embedding_sweep_limit)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns the distinct [tenant_key, priority] pairs it moved to pending.
|
|
54
|
+
def requeue(scope)
|
|
55
|
+
pairs = scope.distinct.pluck(:tenant_key, :priority)
|
|
56
|
+
scope.update_all(status: "pending", attempts: 0, claimed_at: nil, updated_at: Time.current) if pairs.any?
|
|
57
|
+
pairs
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Jobs
|
|
3
|
+
# Plans one Smart run: budget, encoding wait, filters, chunk fan-out.
|
|
4
|
+
# The only argument is the run id; the query waits in the cache
|
|
5
|
+
# (encrypted on encrypted models).
|
|
6
|
+
class SmartSearchJob < ActiveJob::Base
|
|
7
|
+
queue_as { Truffler.config.queue_name }
|
|
8
|
+
|
|
9
|
+
def perform(run_id)
|
|
10
|
+
SmartSearch::Dispatcher.new.call(SmartSearch::Run.load(run_id))
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# One typed label question. Nouls store their probability, scores their
|
|
3
|
+
# normalized position, and choices one row per option ("label:option") with
|
|
4
|
+
# that option's probability. Choice options may be a callable of the tenant
|
|
5
|
+
# key, which makes the vocabulary per-tenant.
|
|
6
|
+
#
|
|
7
|
+
# A label with `from:` is supplied by the host: its answer is read from the
|
|
8
|
+
# record in the shape Jev answers normalize to and Jev is never asked. Its
|
|
9
|
+
# question is optional, `watch:` names extra columns that refresh it, and
|
|
10
|
+
# `version:` forces a refresh when its logic changes.
|
|
11
|
+
class LabelDefinition
|
|
12
|
+
TYPES = %i[noul choice score].freeze
|
|
13
|
+
KEY = /\A[a-z][a-z0-9_]*\z/
|
|
14
|
+
|
|
15
|
+
attr_reader :key, :type, :instructions, :filter_at, :boost, :watch, :version
|
|
16
|
+
# Intent weight a filter decision adds to the KTD20 query vector (default 0).
|
|
17
|
+
attr_reader :filter_weight
|
|
18
|
+
|
|
19
|
+
def initialize(key, type, question: nil, criteria: nil, options: nil, legend: nil, filter_at: nil, boost: nil, filter_weight: 0.0,
|
|
20
|
+
description: nil, from: nil, watch: nil, version: nil)
|
|
21
|
+
@key = key.to_s
|
|
22
|
+
@type = type.to_sym
|
|
23
|
+
@instructions = question
|
|
24
|
+
@criteria = criteria
|
|
25
|
+
@options = options
|
|
26
|
+
@legend = legend
|
|
27
|
+
@filter_at = filter_at&.to_f
|
|
28
|
+
@boost = boost&.to_f
|
|
29
|
+
@filter_weight = Float(filter_weight)
|
|
30
|
+
@description = description
|
|
31
|
+
@from = from
|
|
32
|
+
@watch = Array(watch).map(&:to_s)
|
|
33
|
+
@version = version
|
|
34
|
+
validate!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def supplied?
|
|
38
|
+
!@from.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The label's wording for query encoding: the description, else the question, else the key.
|
|
42
|
+
def description
|
|
43
|
+
@description.presence || instructions.presence || key
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def per_tenant?
|
|
47
|
+
@options.respond_to?(:call)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def options(tenant_key = nil)
|
|
51
|
+
options = per_tenant? ? @options.call(tenant_key) : @options
|
|
52
|
+
options = Array(options).to_h { |option| [ option, nil ] } unless options.is_a?(Hash)
|
|
53
|
+
raise DefinitionError, "#{key}: choice options for tenant #{tenant_key.inspect} are empty" if options.empty?
|
|
54
|
+
|
|
55
|
+
options.to_h { |option, description| [ option.to_s, description ] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def question(tenant_key = nil)
|
|
59
|
+
wording = instructions.presence || description
|
|
60
|
+
Questions.build do |questions|
|
|
61
|
+
case type
|
|
62
|
+
when :noul then questions.noul(key, instructions: wording, criteria: @criteria)
|
|
63
|
+
when :choice then questions.choice(key, instructions: wording, criteria: options(tenant_key))
|
|
64
|
+
when :score then questions.score(key, instructions: wording, criteria: @legend)
|
|
65
|
+
end
|
|
66
|
+
end.fetch(key)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Stored supplied values change with their shape and version, never with
|
|
70
|
+
# the Jev model. The description and option wording are digested too
|
|
71
|
+
# because query encoding asks about them.
|
|
72
|
+
def supplied_fingerprint(tenant_key = nil)
|
|
73
|
+
Canonical.digest(supplied: true, type: type, options: (options(tenant_key) if type == :choice),
|
|
74
|
+
levels: (levels if type == :score), description: description, version: version)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# {storage_key => value} read from the record, or nil when the host has
|
|
78
|
+
# no answer. Raises InvalidSuppliedAnswer for a value out of shape.
|
|
79
|
+
def supplied_values(record, tenant_key = nil)
|
|
80
|
+
value = @from.call(record)
|
|
81
|
+
return if value.nil?
|
|
82
|
+
|
|
83
|
+
case type
|
|
84
|
+
when :noul then { key => probability(value) }
|
|
85
|
+
when :score then { key => level(value) }
|
|
86
|
+
when :choice then choice_values(value, options(tenant_key).keys)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def storage_keys(tenant_key = nil)
|
|
91
|
+
type == :choice ? options(tenant_key).keys.map { |option| "#{key}:#{option}" } : [ key ]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The label part of its Jev question id ("<tag>__<question_key>").
|
|
95
|
+
def question_key
|
|
96
|
+
key
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def levels
|
|
102
|
+
Array(@legend).size
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def probability(value)
|
|
106
|
+
return value ? 1.0 : 0.0 if [ true, false ].include?(value)
|
|
107
|
+
return value.to_f if value.is_a?(Numeric) && value.between?(0, 1)
|
|
108
|
+
|
|
109
|
+
raise InvalidSuppliedAnswer, "#{key}: expected a probability from 0 to 1 or true/false, got #{value.class}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def level(value)
|
|
113
|
+
return value.to_f / (levels - 1) if value.is_a?(Integer) && value.between?(0, levels - 1)
|
|
114
|
+
|
|
115
|
+
raise InvalidSuppliedAnswer, "#{key}: expected a level index from 0 to #{levels - 1}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def choice_values(value, options)
|
|
119
|
+
probabilities = value.is_a?(Hash) ? value.to_h { |option, share| [ option.to_s, share ] } : { value.to_s => 1.0 }
|
|
120
|
+
unknown = probabilities.keys - options
|
|
121
|
+
raise InvalidSuppliedAnswer, "#{key}: #{unknown.size} answer option(s) are not declared options" if unknown.any?
|
|
122
|
+
|
|
123
|
+
options.to_h { |option| [ "#{key}:#{option}", probabilities.key?(option) ? probability(probabilities[option]) : 0.0 ] }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate!
|
|
127
|
+
raise DefinitionError, "label #{key.inspect} must match #{KEY.source} without a double underscore" unless valid_key?
|
|
128
|
+
raise DefinitionError, "label #{key.inspect} is reserved for lens dimensions" if key == Lenses::KEY_PREFIX
|
|
129
|
+
raise DefinitionError, "#{key}: type must be one of #{TYPES.join(', ')}" unless TYPES.include?(type)
|
|
130
|
+
validate_supplied!
|
|
131
|
+
raise DefinitionError, "#{key}: choice labels need options:" if type == :choice && @options.blank?
|
|
132
|
+
raise DefinitionError, "#{key}: score labels need a legend: of at least two levels" if type == :score && levels < 2
|
|
133
|
+
|
|
134
|
+
question unless per_tenant?
|
|
135
|
+
rescue ArgumentError => error
|
|
136
|
+
raise DefinitionError, error.message
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def validate_supplied!
|
|
140
|
+
unless supplied?
|
|
141
|
+
raise DefinitionError, "#{key}: a question is required" if instructions.blank?
|
|
142
|
+
raise DefinitionError, "#{key}: watch: and version: need from:" if @watch.any? || !@version.nil?
|
|
143
|
+
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
raise DefinitionError, "#{key}: from: must be callable with the record" unless @from.respond_to?(:call)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def valid_key?
|
|
150
|
+
KEY.match?(key) && !key.include?(Questions::SEPARATOR)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Labeling
|
|
3
|
+
# Relabels one model's records whose labels are missing, stale (labeled
|
|
4
|
+
# under another vocabulary version), failed, or demoted to backfill
|
|
5
|
+
# priority. It walks newest-first below an id cursor, splits each page by
|
|
6
|
+
# tenant, packs batch_size records per request at backfill priority, and
|
|
7
|
+
# asks only the stale questions. Live pending rows belong to the flush job
|
|
8
|
+
# and are never touched.
|
|
9
|
+
#
|
|
10
|
+
# Resumable: the cursor moves past a page only once the whole page is
|
|
11
|
+
# done, and records already current are skipped, so a rerun never asks
|
|
12
|
+
# Jev about them again. A spend cap stops the run before a request would
|
|
13
|
+
# exceed it; host-supplied labels cost nothing, so they are still written
|
|
14
|
+
# once the cap is reached. A Jev error releases the claimed rows and ends
|
|
15
|
+
# the run with `:client_error`, so the caller keeps the spend metered so far.
|
|
16
|
+
class Backfill
|
|
17
|
+
Result = Data.define(:status, :labeled, :requests, :cost, :cursor)
|
|
18
|
+
|
|
19
|
+
STATES = Records::RecordState.table_name
|
|
20
|
+
|
|
21
|
+
class SpendCapReached < StandardError; end
|
|
22
|
+
|
|
23
|
+
# Wraps the client to meter spend per request and refuse a request whose
|
|
24
|
+
# estimated cost would push spend past the cap.
|
|
25
|
+
class SpendMeter
|
|
26
|
+
attr_reader :spent, :requests
|
|
27
|
+
|
|
28
|
+
def initialize(client, cap:, spent:, config: Truffler.config)
|
|
29
|
+
@client = client
|
|
30
|
+
@cap = cap
|
|
31
|
+
@spent = spent.to_f
|
|
32
|
+
@requests = 0
|
|
33
|
+
@config = config
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ask(state:, questions:, **options)
|
|
37
|
+
raise SpendCapReached if @cap && @spent + estimate(state, questions) > @cap
|
|
38
|
+
|
|
39
|
+
answers = @client.ask(state: state, questions: questions, **options)
|
|
40
|
+
@requests += 1
|
|
41
|
+
@spent += answers.usage&.cost.to_f
|
|
42
|
+
answers
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def estimate(state, questions)
|
|
48
|
+
@config.cost_for(Tokens.estimate({ state: state, questions: questions }))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.status(model)
|
|
53
|
+
new(model).status
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
attr_reader :model, :batch_size, :page_size
|
|
57
|
+
|
|
58
|
+
def initialize(model, spend_cap: Truffler.config.backfill_spend_cap, batch_size: Truffler.config.batch_size,
|
|
59
|
+
page_size: nil, cursor: nil, spent: 0.0, client: Truffler.config.client, budget: Budget.new)
|
|
60
|
+
@model = model
|
|
61
|
+
@batch_size = batch_size
|
|
62
|
+
@page_size = page_size || batch_size * 5
|
|
63
|
+
@cursor = cursor
|
|
64
|
+
@meter = SpendMeter.new(client, cap: spend_cap, spent: spent)
|
|
65
|
+
@budget = budget
|
|
66
|
+
@versions = {}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run(max_pages: nil)
|
|
70
|
+
@labeled = 0
|
|
71
|
+
@started_spent = @meter.spent
|
|
72
|
+
cursor = @cursor
|
|
73
|
+
pages = 0
|
|
74
|
+
|
|
75
|
+
loop do
|
|
76
|
+
scanned, rows = page(cursor)
|
|
77
|
+
return result(:complete, nil) if scanned.empty?
|
|
78
|
+
return result(:paused, cursor) if max_pages && pages >= max_pages
|
|
79
|
+
|
|
80
|
+
rows.group_by(&:last).each do |tenant_key, tenant_rows|
|
|
81
|
+
tenant_rows.map(&:first).each_slice(batch_size) do |ids|
|
|
82
|
+
stop = label(ids, tenant_key)
|
|
83
|
+
return result(stop, cursor) if stop
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
cursor = scanned.last
|
|
88
|
+
pages += 1
|
|
89
|
+
return result(:complete, nil) if scanned.size < page_size
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def status
|
|
94
|
+
counts = states.group(:status).count
|
|
95
|
+
labeled = states.where(status: "labeled").group(:tenant_key, :vocabulary_version).count
|
|
96
|
+
stale = labeled.sum { |(tenant_key, version), count| version == version_for(tenant_key) ? 0 : count }
|
|
97
|
+
{ total: model.count, missing: model.joins(state_join).where("#{STATES}.id IS NULL").count,
|
|
98
|
+
pending: counts["pending"].to_i, labeling: counts["labeling"].to_i, labeled: counts["labeled"].to_i,
|
|
99
|
+
failed: counts["failed"].to_i, stale: stale, current: labeled.values.sum - stale }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def definition
|
|
105
|
+
model.truffler_definition
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def record_type
|
|
109
|
+
model.polymorphic_name
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def states
|
|
113
|
+
Records::RecordState.for_model(model)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def queue
|
|
117
|
+
@queue ||= Queue.new(model)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def version_for(tenant_key)
|
|
121
|
+
@versions[tenant_key] ||= definition.vocabulary.version(tenant_key: tenant_key, all_users: true)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def result(status, cursor)
|
|
125
|
+
Result.new(status: status, labeled: @labeled, requests: @meter.requests, cost: @meter.spent - @started_spent,
|
|
126
|
+
cursor: cursor)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Returns the scanned ids (for the cursor) and the [id, tenant_key] rows
|
|
130
|
+
# that still need labels. Per-tenant vocabularies (per-tenant choices or
|
|
131
|
+
# lenses) compare versions here because each tenant has its own.
|
|
132
|
+
def page(cursor)
|
|
133
|
+
pk = model.primary_key
|
|
134
|
+
scope = model.joins(state_join).where(needs_labeling_sql)
|
|
135
|
+
scope = scope.where(model.arel_table[pk].lt(cursor)) if cursor
|
|
136
|
+
tenant = definition.scoped? ? model.arel_table[definition.tenant_column] : Arel.sql("NULL")
|
|
137
|
+
plucked = scope.reorder(pk => :desc).limit(page_size)
|
|
138
|
+
.pluck(model.arel_table[pk], tenant, Arel.sql("#{STATES}.status"), Arel.sql("#{STATES}.vocabulary_version"))
|
|
139
|
+
|
|
140
|
+
rows = plucked.filter_map do |id, tenant_key, status, version|
|
|
141
|
+
tenant_key = tenant_key&.to_s
|
|
142
|
+
[ id, tenant_key ] unless status == "labeled" && version == version_for(tenant_key)
|
|
143
|
+
end
|
|
144
|
+
[ plucked.map(&:first), rows ]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def state_join
|
|
148
|
+
pk = "#{model.quoted_table_name}.#{model.connection.quote_column_name(model.primary_key)}"
|
|
149
|
+
ActiveRecord::Base.sanitize_sql_array([
|
|
150
|
+
"LEFT OUTER JOIN #{STATES} ON #{STATES}.record_type = ? AND #{STATES}.record_id = #{pk}", record_type
|
|
151
|
+
])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def needs_labeling_sql
|
|
155
|
+
stale = if definition.per_tenant_vocabulary? || Lenses::Lens.active.for_model(model).exists?
|
|
156
|
+
"#{STATES}.status = 'labeled'"
|
|
157
|
+
else
|
|
158
|
+
ActiveRecord::Base.sanitize_sql_array([
|
|
159
|
+
"(#{STATES}.status = 'labeled' AND (#{STATES}.vocabulary_version IS NULL OR #{STATES}.vocabulary_version <> ?))",
|
|
160
|
+
version_for(nil)
|
|
161
|
+
])
|
|
162
|
+
end
|
|
163
|
+
"#{STATES}.id IS NULL OR #{STATES}.status = 'failed' OR " \
|
|
164
|
+
"(#{STATES}.status = 'pending' AND #{STATES}.priority = 'backfill') OR #{stale}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Labels one tenant chunk. Returns nil when done, or the status that
|
|
168
|
+
# stops the run; claimed rows that were not labeled go back to pending
|
|
169
|
+
# at backfill priority.
|
|
170
|
+
def label(ids, tenant_key)
|
|
171
|
+
claimed = queue.claim_backfill(ids, tenant_key)
|
|
172
|
+
return if claimed.empty?
|
|
173
|
+
|
|
174
|
+
begin
|
|
175
|
+
Labeler.new(model, client: @meter, budget: @budget).label(claimed, priority: :backfill)
|
|
176
|
+
rescue BudgetExhausted, SpendCapReached => error
|
|
177
|
+
queue.demote(claimed)
|
|
178
|
+
return error.is_a?(SpendCapReached) ? :spend_cap_reached : :budget_denied
|
|
179
|
+
rescue ClientError, IncompleteAnswers => error
|
|
180
|
+
queue.release(claimed, error)
|
|
181
|
+
return :client_error
|
|
182
|
+
ensure
|
|
183
|
+
@labeled += Records::RecordState.where(id: claimed.map(&:id), status: "labeled").count
|
|
184
|
+
end
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Labeling
|
|
3
|
+
# Labels claimed record states of one tenant. It asks Jev only the labels
|
|
4
|
+
# that are missing or whose fingerprint is stale, packs records into as
|
|
5
|
+
# few requests as the budget allows, stores each answer as numeric label
|
|
6
|
+
# rows, and marks states labeled as each request lands, so a failure
|
|
7
|
+
# partway leaves finished records finished. Stale host-supplied labels
|
|
8
|
+
# are written first, before any budget slot or Jev call, so a Jev outage
|
|
9
|
+
# or denial never holds them back.
|
|
10
|
+
class Labeler
|
|
11
|
+
Result = Data.define(:labeled, :requests, :cost, :demoted)
|
|
12
|
+
|
|
13
|
+
attr_reader :model, :client, :budget
|
|
14
|
+
|
|
15
|
+
def initialize(model, client: Truffler.config.client, budget: Budget.new)
|
|
16
|
+
@model = model
|
|
17
|
+
@client = client
|
|
18
|
+
@budget = budget
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def label(states, priority:)
|
|
22
|
+
return Result.new(labeled: 0, requests: 0, cost: 0.0, demoted: false) if states.empty?
|
|
23
|
+
|
|
24
|
+
tenant_key = tenant_key_of(states)
|
|
25
|
+
records = load_records(states)
|
|
26
|
+
vocabulary = definition.vocabulary
|
|
27
|
+
@labels = vocabulary.labels_for(tenant_key: tenant_key, all_users: true)
|
|
28
|
+
fingerprints = vocabulary.fingerprints(tenant_key: tenant_key, all_users: true)
|
|
29
|
+
version = vocabulary.version(tenant_key: tenant_key, all_users: true)
|
|
30
|
+
states_by_id = states.index_by { |state| state.record_id.to_s }
|
|
31
|
+
|
|
32
|
+
stored = stored_fingerprints(records)
|
|
33
|
+
askable = askable_labels
|
|
34
|
+
stale = records.map { |record| [ record, stale_keys(stored[record.id.to_s].to_h, fingerprints, tenant_key, askable) ] }
|
|
35
|
+
supplied = askable.select(&:supplied?).map(&:key)
|
|
36
|
+
supplier = Supplied.new(model)
|
|
37
|
+
written = supplier.write(stale.map { |record, keys| [ record, keys & supplied ] }, tenant_key: tenant_key)
|
|
38
|
+
current, pending = stale.map { |record, keys| [ record, keys - supplied ] }.partition { |_, keys| keys.empty? }
|
|
39
|
+
settled = current.reject { |record, _| supplier.failed_ids.include?(record.id) }
|
|
40
|
+
Records::RecordState.mark_labeled(settled.map { |record, _| states_by_id[record.id.to_s].id }, version: version)
|
|
41
|
+
Embeddings::LabelVector.new(model).write(current.map { |record, _| record.id } - written, tenant_key: tenant_key)
|
|
42
|
+
|
|
43
|
+
requests = RequestBuilder.new(definition, tenant_key: tenant_key, labels: @labels).build(pending)
|
|
44
|
+
cost = 0.0
|
|
45
|
+
requests.each_with_index do |request, index|
|
|
46
|
+
decision = budget.acquire(priority: priority, tenant_key: (tenant_key if index.zero?),
|
|
47
|
+
records: index.zero? ? pending.size : 1)
|
|
48
|
+
if decision.demoted?
|
|
49
|
+
retry_failed_supplied(supplier.failed_ids, states_by_id)
|
|
50
|
+
return Result.new(labeled: current.size, requests: index, cost: cost, demoted: true)
|
|
51
|
+
end
|
|
52
|
+
raise BudgetExhausted, "no Jev budget for #{priority} labeling" if decision.denied?
|
|
53
|
+
|
|
54
|
+
answers = client.ask(state: request.state, questions: request.questions, priority: decision.priority)
|
|
55
|
+
cost += answers.usage&.cost.to_f
|
|
56
|
+
charge_lenses(request, answers.usage&.cost.to_f)
|
|
57
|
+
store(request, answers, fingerprints, tenant_key, version, states_by_id)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
retry_failed_supplied(supplier.failed_ids, states_by_id)
|
|
61
|
+
Result.new(labeled: records.size, requests: requests.size, cost: cost, demoted: false)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# A failed `from` must not leave its record looking current, or backfill
|
|
67
|
+
# would never ask again. Supplied labels cost nothing, so retrying at
|
|
68
|
+
# backfill priority is free.
|
|
69
|
+
def retry_failed_supplied(record_ids, states_by_id)
|
|
70
|
+
ids = record_ids.filter_map { |id| states_by_id[id.to_s]&.id }
|
|
71
|
+
Records::RecordState.mark_supplied_failed(ids, error_class: SuppliedLabelFailed.name) if ids.any?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def definition
|
|
75
|
+
model.truffler_definition
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def record_type
|
|
79
|
+
model.polymorphic_name
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def tenant_key_of(states)
|
|
83
|
+
tenants = states.map(&:tenant_key).uniq
|
|
84
|
+
raise TenantMismatch, "labeling holds records from exactly one tenant" if tenants.size > 1
|
|
85
|
+
|
|
86
|
+
tenants.first
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def load_records(states)
|
|
90
|
+
records = model.where(model.primary_key => states.map(&:record_id)).to_a
|
|
91
|
+
found = records.map { |record| record.id.to_s }
|
|
92
|
+
gone = states.reject { |state| found.include?(state.record_id.to_s) }
|
|
93
|
+
Records::RecordState.where(id: gone.map(&:id)).delete_all if gone.any?
|
|
94
|
+
records
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stale_keys(stored, fingerprints, tenant_key, askable)
|
|
98
|
+
askable.reject do |label|
|
|
99
|
+
label.storage_keys(tenant_key).all? { |key| stored[key] == fingerprints[label.key] }
|
|
100
|
+
end.map(&:key)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Every label except those of lenses at their spend cap (R43).
|
|
104
|
+
def askable_labels
|
|
105
|
+
lens_labels = @labels.values.grep(Lenses::LensLabel)
|
|
106
|
+
@lenses = nil
|
|
107
|
+
return @labels.values if lens_labels.empty?
|
|
108
|
+
|
|
109
|
+
@lenses = Lenses::Lens.where(id: lens_labels.map(&:lens_id).uniq).index_by(&:id)
|
|
110
|
+
@labels.values.reject { |label| label.is_a?(Lenses::LensLabel) && @lenses[label.lens_id]&.spend_cap_reached? }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Splits a request's cost over its questions and adds each lens's share
|
|
114
|
+
# to that lens's spend.
|
|
115
|
+
def charge_lenses(request, cost)
|
|
116
|
+
return if @lenses.blank? || cost.zero?
|
|
117
|
+
|
|
118
|
+
keys = request.entries.values.flat_map { |_, keys| keys }
|
|
119
|
+
keys.filter_map { |key| @labels[key] }.grep(Lenses::LensLabel).group_by(&:lens_id).each do |lens_id, asked|
|
|
120
|
+
@lenses[lens_id]&.record_spend!(cost * asked.size / keys.size)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def stored_fingerprints(records)
|
|
125
|
+
rows = Records::Label.where(record_type: record_type, record_id: records.map(&:id)).pluck(:record_id, :label_key, :fingerprint)
|
|
126
|
+
rows.each_with_object({}) { |(id, key, print), map| (map[id.to_s] ||= {})[key] = print }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def store(request, answers, fingerprints, tenant_key, version, states_by_id)
|
|
130
|
+
now = Time.current
|
|
131
|
+
rows = request.entries.flat_map do |tag, (record, keys)|
|
|
132
|
+
keys.flat_map do |key|
|
|
133
|
+
label = @labels.fetch(key)
|
|
134
|
+
label_rows(label, Questions.tagged_id(tag, label.question_key), answers, tenant_key).map do |label_key, value|
|
|
135
|
+
{ record_type: record_type, record_id: record.id, tenant_key: tenant_key, label_key: label_key,
|
|
136
|
+
value: value, fingerprint: fingerprints[key], labeled_at: now }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
Records::Label.transaction do
|
|
142
|
+
request.entries.each_value do |record, keys|
|
|
143
|
+
keys.each { |key| Records::Label.where(record_type: record_type, record_id: record.id).for_label(key).delete_all }
|
|
144
|
+
end
|
|
145
|
+
Records::Label.insert_all!(rows) if rows.any?
|
|
146
|
+
Embeddings::LabelVector.new(model).write(request.entries.values.map { |record, _| record.id }, tenant_key: tenant_key)
|
|
147
|
+
ids = request.entries.values.map { |record, _| states_by_id[record.id.to_s].id }
|
|
148
|
+
Records::RecordState.mark_labeled(ids, version: version)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def label_rows(label, id, answers, tenant_key)
|
|
153
|
+
case label.type
|
|
154
|
+
when :noul then [ [ label.key, answers.noul(id) ] ]
|
|
155
|
+
when :score then [ [ label.key, answers.score(id) ] ]
|
|
156
|
+
when :choice then label.options(tenant_key).keys.map { |option| [ "#{label.key}:#{option}", answers.probability(id, option) ] }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|