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,49 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Clients
|
|
3
|
+
# Every adapter answers `ask(state:, questions:, model:, priority:)` with
|
|
4
|
+
# Truffler::Answers. Subclasses implement `perform(state:, questions:,
|
|
5
|
+
# model:)`, returning the parsed TypeSafe body: {"answers", "model",
|
|
6
|
+
# "usage" => {"input_tokens"}}. Only "answers" is required; missing token
|
|
7
|
+
# counts are estimated from the request size and flagged.
|
|
8
|
+
class Base
|
|
9
|
+
def ask(state:, questions:, model: nil, priority: nil)
|
|
10
|
+
model ||= Truffler.config.model
|
|
11
|
+
started = Instrumentation.monotonic_ms
|
|
12
|
+
payload = { priority: priority, question_count: questions.size, model: model }
|
|
13
|
+
|
|
14
|
+
response = normalize(perform(state: state, questions: questions, model: model))
|
|
15
|
+
usage = usage_for(response, state, questions)
|
|
16
|
+
answers = Answers.new(response["answers"], requested: questions.keys, model: response["model"] || model, usage: usage)
|
|
17
|
+
payload.merge!(model: answers.model, input_tokens: usage.input_tokens, tokens_estimated: usage.estimated, cost: usage.cost)
|
|
18
|
+
answers
|
|
19
|
+
rescue Truffler::Error => error
|
|
20
|
+
payload[:error_class] = error.class.name
|
|
21
|
+
raise
|
|
22
|
+
rescue StandardError => error
|
|
23
|
+
wrapped = ClientError.from(error)
|
|
24
|
+
payload.merge!(error_class: wrapped.error_class, status: wrapped.status)
|
|
25
|
+
raise wrapped, cause: nil
|
|
26
|
+
ensure
|
|
27
|
+
Instrumentation.instrument(:jev_call, payload.merge(latency_ms: Instrumentation.monotonic_ms - started))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def perform(state:, questions:, model:)
|
|
31
|
+
raise NotImplementedError, "#{self.class.name}#perform"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def normalize(response)
|
|
37
|
+
response = response.to_h.deep_stringify_keys
|
|
38
|
+
response.key?("answers") ? response : { "answers" => response }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def usage_for(response, state, questions)
|
|
42
|
+
tokens = response.dig("usage", "input_tokens")
|
|
43
|
+
return Usage.new(input_tokens: tokens.to_i, estimated: false) if tokens
|
|
44
|
+
|
|
45
|
+
Usage.new(input_tokens: Tokens.estimate({ state: state, questions: questions }), estimated: true)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Clients
|
|
3
|
+
# Wraps a host client that responds to `evaluate(state:, schema:)`, such as
|
|
4
|
+
# Cora's TypeSafeClient. The schema is the question hash in wire shape. The
|
|
5
|
+
# pinned model is passed only when the host's method accepts `model:`.
|
|
6
|
+
class Callable < Base
|
|
7
|
+
def initialize(host)
|
|
8
|
+
@host = host
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def perform(state:, questions:, model:)
|
|
12
|
+
if accepts_model?
|
|
13
|
+
@host.evaluate(state: state, schema: questions, model: model)
|
|
14
|
+
else
|
|
15
|
+
@host.evaluate(state: state, schema: questions)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def accepts_model?
|
|
22
|
+
@host.method(:evaluate).parameters.any? { |kind, name| name == :model || kind == :keyrest }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Truffler
|
|
4
|
+
module Clients
|
|
5
|
+
# Records or replays any adapter's responses, keyed by the SHA-256 of the
|
|
6
|
+
# canonical request JSON ({model, state, questions}). A recording stores
|
|
7
|
+
# only the answers, the answering model, and usage, never the request.
|
|
8
|
+
#
|
|
9
|
+
# Modes: :replay (a miss raises CassetteMiss), :record (always calls the
|
|
10
|
+
# inner client), :auto (replays hits, records misses).
|
|
11
|
+
class Cassette < Base
|
|
12
|
+
MODES = %i[replay record auto].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(inner, dir:, mode: :replay)
|
|
15
|
+
raise ArgumentError, "cassette mode must be one of #{MODES.join(', ')}" unless MODES.include?(mode.to_sym)
|
|
16
|
+
|
|
17
|
+
@inner = inner
|
|
18
|
+
@dir = dir.to_s
|
|
19
|
+
@mode = mode.to_sym
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def perform(state:, questions:, model:)
|
|
23
|
+
hash = Canonical.digest(model: model, state: state, questions: questions)
|
|
24
|
+
path = File.join(@dir, "#{hash}.json")
|
|
25
|
+
return JSON.parse(File.read(path)) if @mode != :record && File.exist?(path)
|
|
26
|
+
raise CassetteMiss, "no recording #{hash} in #{@dir}" if @mode == :replay || @inner.nil?
|
|
27
|
+
|
|
28
|
+
record(path, hash, @inner.perform(state: state, questions: questions, model: model))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def record(path, hash, response)
|
|
34
|
+
response = normalize(response)
|
|
35
|
+
stored = response.slice("answers", "model", "usage").merge("request_hash" => hash)
|
|
36
|
+
FileUtils.mkdir_p(@dir)
|
|
37
|
+
temp = "#{path}.#{Process.pid}.tmp"
|
|
38
|
+
File.write(temp, "#{Canonical.json(stored)}\n")
|
|
39
|
+
File.rename(temp, path)
|
|
40
|
+
stored
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Clients
|
|
3
|
+
# Deterministic scripted answers for tests and benchmarks. Script by exact
|
|
4
|
+
# question id or by label suffix (`:spam` matches `r001__spam`):
|
|
5
|
+
#
|
|
6
|
+
# fake.answer(:spam, 0.9) # noul probability
|
|
7
|
+
# fake.answer(:tone, "angry") # choice option
|
|
8
|
+
# fake.answer(:tone, { "calm" => 0.2, "angry" => 0.8 })
|
|
9
|
+
# fake.answer(:urgency, 2) # score level
|
|
10
|
+
# fake.answer(:spam) { |tag, state| state.dig("records", tag, "body").include?("$$$") ? 0.9 : 0.1 }
|
|
11
|
+
#
|
|
12
|
+
# Unscripted questions answer no, the first option, or the lowest level.
|
|
13
|
+
class Fake < Base
|
|
14
|
+
attr_reader :calls
|
|
15
|
+
|
|
16
|
+
def initialize(model: nil, &default)
|
|
17
|
+
@model = model
|
|
18
|
+
@default = default
|
|
19
|
+
@scripts = {}
|
|
20
|
+
@omitted = []
|
|
21
|
+
@error = nil
|
|
22
|
+
@calls = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def answer(key, value = nil, &block)
|
|
26
|
+
@scripts[key.to_s] = block || value
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def answer_without(*ids)
|
|
31
|
+
@omitted = ids.map(&:to_s)
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fail_with(error)
|
|
36
|
+
@error = error
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def perform(state:, questions:, model:)
|
|
41
|
+
@calls << { state: state, questions: questions, model: model }
|
|
42
|
+
raise @error if @error
|
|
43
|
+
|
|
44
|
+
answers = questions.except(*@omitted).to_h { |id, question| [ id, answer_for(id, question, state) ] }
|
|
45
|
+
{ "answers" => answers, "model" => @model || model }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def answer_for(id, question, state)
|
|
51
|
+
tag, label = Questions.split_id(id)
|
|
52
|
+
script = @scripts.fetch(id) { @scripts[label] if label }
|
|
53
|
+
script = @default if script.nil?
|
|
54
|
+
value = script.respond_to?(:call) ? script.call(tag, state.deep_stringify_keys, id) : script
|
|
55
|
+
shape(question, value)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def shape(question, value)
|
|
59
|
+
case question["type"]
|
|
60
|
+
when "noul" then { "type" => "noul", "noul" => value.to_f }
|
|
61
|
+
when "choice" then choice(question["criteria"].keys, value)
|
|
62
|
+
when "score" then score(question["criteria"].size, value.to_f)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def choice(options, value)
|
|
67
|
+
probabilities = options.to_h { |option| [ option, 0.0 ] }
|
|
68
|
+
case value
|
|
69
|
+
when Hash then probabilities.merge!(value.transform_keys(&:to_s).transform_values(&:to_f))
|
|
70
|
+
when nil then probabilities[options.first] = 1.0
|
|
71
|
+
else probabilities[value.to_s] = 1.0
|
|
72
|
+
end
|
|
73
|
+
pick, confidence = probabilities.max_by { |_, probability| probability }
|
|
74
|
+
{ "type" => "choice", "choice" => pick, "probabilities" => probabilities, "confidence" => confidence }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def score(levels, level)
|
|
78
|
+
legend = Array.new(levels) { |index| [ index.to_s, index.to_s ] }.to_h
|
|
79
|
+
probabilities = legend.keys.to_h { |key| [ key, key.to_i == level.round ? 1.0 : 0.0 ] }
|
|
80
|
+
{ "type" => "score", "score" => level, "legend" => legend, "probabilities" => probabilities,
|
|
81
|
+
"confidence" => 1.0 }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "ruby_llm"
|
|
2
|
+
require "ruby_llm-typesafe"
|
|
3
|
+
|
|
4
|
+
module Truffler
|
|
5
|
+
module Clients
|
|
6
|
+
# The default adapter: TypeSafe through RubyLLM 2 and ruby_llm-typesafe.
|
|
7
|
+
# Neither gem is a truffler dependency; this file loads only when the
|
|
8
|
+
# adapter is selected.
|
|
9
|
+
class RubyLLMTypeSafe < Base
|
|
10
|
+
def perform(state:, questions:, model:)
|
|
11
|
+
message = chat(model: model, provider: :typesafe).with_schema(schema_for(questions)).ask(JSON.generate(state))
|
|
12
|
+
{ "answers" => message.parsed, "model" => message.model, "usage" => { "input_tokens" => message.tokens&.input } }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def chat(**options)
|
|
18
|
+
RubyLLM.chat(**options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def schema_for(questions)
|
|
22
|
+
RubyLLM::Providers::TypeSafe::Schema.new do |schema|
|
|
23
|
+
questions.each do |id, question|
|
|
24
|
+
options = { instructions: question["instructions"] }
|
|
25
|
+
options[:criteria] = question["criteria"] if question.key?("criteria")
|
|
26
|
+
schema.public_send(question["type"], id, **options)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
class Configuration
|
|
3
|
+
DEFAULT_REQUESTS_PER_MINUTE = 1_200
|
|
4
|
+
DEFAULT_PRIORITY_CEILINGS = { live: 1.0, encode: 0.9, rerank: 0.75, backfill: 0.5 }.freeze
|
|
5
|
+
DEFAULT_USER_CAPS = { encode: 30, rerank: 10 }.freeze
|
|
6
|
+
|
|
7
|
+
attr_accessor :model, :cost_per_million_tokens, :requests_per_minute, :headroom, :priority_ceilings,
|
|
8
|
+
:user_caps, :tenant_live_cap, :max_wait, :batch_size, :grouping_window, :max_attempts,
|
|
9
|
+
:max_field_chars, :request_token_budget, :max_questions_per_request, :queue_name,
|
|
10
|
+
:embedder, :encryptor, :backfill_spend_cap, :resume_pending_after
|
|
11
|
+
attr_writer :client, :cache_store, :logger
|
|
12
|
+
attr_accessor :miss_retention, :miss_min_distinct_users
|
|
13
|
+
attr_writer :secret_key_base
|
|
14
|
+
attr_accessor :vector_store, :embedding_cost_per_million_tokens
|
|
15
|
+
attr_accessor :encoding_prefetch
|
|
16
|
+
attr_reader :lenses
|
|
17
|
+
attr_accessor :encoding_deadline, :rerank_depth, :rerank_chunk_size, :rerank_max_field_chars, :smart_thresholds,
|
|
18
|
+
:smart_run_ttl, :smart_candidate_pool, :broadcaster
|
|
19
|
+
|
|
20
|
+
def initialize(env: ENV)
|
|
21
|
+
@model = "jev-latest"
|
|
22
|
+
@cost_per_million_tokens = 0.042
|
|
23
|
+
@requests_per_minute = Integer(env["TYPESAFE_REQUESTS_PER_MINUTE"].presence || DEFAULT_REQUESTS_PER_MINUTE)
|
|
24
|
+
@headroom = 0.25
|
|
25
|
+
@priority_ceilings = DEFAULT_PRIORITY_CEILINGS.dup
|
|
26
|
+
@user_caps = DEFAULT_USER_CAPS.dup
|
|
27
|
+
@tenant_live_cap = 120
|
|
28
|
+
@max_wait = 5.0
|
|
29
|
+
@batch_size = 10
|
|
30
|
+
@grouping_window = 0
|
|
31
|
+
@max_attempts = 5
|
|
32
|
+
@max_field_chars = 4_000
|
|
33
|
+
@request_token_budget = 48_000
|
|
34
|
+
@max_questions_per_request = 200
|
|
35
|
+
@queue_name = :default
|
|
36
|
+
@miss_retention = 30.days
|
|
37
|
+
@miss_min_distinct_users = 5
|
|
38
|
+
@backfill_spend_cap = nil
|
|
39
|
+
@resume_pending_after = 5.minutes
|
|
40
|
+
@vector_store = :auto
|
|
41
|
+
@embedding_cost_per_million_tokens = 0.02
|
|
42
|
+
@lenses = Lenses::Settings.new
|
|
43
|
+
@encoding_prefetch = QueryEncoding::Prefetch.new
|
|
44
|
+
@encoding_deadline = 1.0
|
|
45
|
+
@rerank_depth = 30
|
|
46
|
+
@rerank_chunk_size = 10
|
|
47
|
+
@rerank_max_field_chars = 1_200
|
|
48
|
+
@smart_thresholds = { strong: 0.70, possible: 0.35 }
|
|
49
|
+
@smart_run_ttl = 15.minutes
|
|
50
|
+
@smart_candidate_pool = 200
|
|
51
|
+
@broadcaster = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def client
|
|
55
|
+
@client ||= Clients::RubyLLMTypeSafe.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cache_store
|
|
59
|
+
@cache_store || rails_cache || (@fallback_cache ||= ActiveSupport::Cache::MemoryStore.new)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def logger
|
|
63
|
+
@logger || rails_logger || (@fallback_logger ||= ActiveSupport::Logger.new(nil))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cost_for(tokens)
|
|
67
|
+
tokens.to_i * cost_per_million_tokens / 1_000_000.0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def secret_key_base
|
|
71
|
+
@secret_key_base.presence || rails_secret_key_base.presence ||
|
|
72
|
+
raise(Error, "Truffler needs config.secret_key_base (or a Rails secret_key_base) to digest query misses")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def rails_cache
|
|
78
|
+
Rails.cache if defined?(Rails) && Rails.respond_to?(:cache)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def rails_logger
|
|
82
|
+
Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def rails_secret_key_base
|
|
86
|
+
Rails.application&.secret_key_base if defined?(Rails) && Rails.respond_to?(:application)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# Everything a model declares in its `truffler do ... end` block.
|
|
3
|
+
class Definition
|
|
4
|
+
EXPLICIT_ACTIONS = %i[enter key row].freeze
|
|
5
|
+
DEFAULT_EMBEDDINGS = { model: "text-embedding-3-small", dimensions: 256 }.freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :model, :fields, :labels, :exact_sources, :providers, :surfaces
|
|
8
|
+
attr_accessor :tenant_column, :keyword, :embeddings, :order, :arrived_at_column
|
|
9
|
+
|
|
10
|
+
def initialize(model)
|
|
11
|
+
@model = model
|
|
12
|
+
@fields = []
|
|
13
|
+
@labels = {}
|
|
14
|
+
@exact_sources = {}
|
|
15
|
+
@providers = {}
|
|
16
|
+
@surfaces = {}
|
|
17
|
+
@arrived_at_column = "created_at"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def add_label(label)
|
|
21
|
+
raise DefinitionError, "label #{label.key} is declared twice" if labels.key?(label.key)
|
|
22
|
+
|
|
23
|
+
labels[label.key] = label
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def label(key)
|
|
27
|
+
labels.fetch(key.to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def label_keys
|
|
31
|
+
labels.keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def scoped?
|
|
35
|
+
tenant_column.present?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def supplied_labels
|
|
39
|
+
labels.values.select(&:supplied?)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def per_tenant_vocabulary?
|
|
43
|
+
labels.each_value.any?(&:per_tenant?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def vocabulary
|
|
47
|
+
Vocabulary.new(self)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def tenant_key_for(record)
|
|
51
|
+
record.public_send(tenant_column)&.to_s if scoped?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def field_values(record)
|
|
55
|
+
fields.index_with { |field| record.public_send(field) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Field values as Jev request state: strings cut to max_chars so one long
|
|
59
|
+
# record cannot crowd out a batch, everything else as JSON.
|
|
60
|
+
def request_fields(record, max_chars:)
|
|
61
|
+
field_values(record).transform_values { |value| value.is_a?(String) ? value[0, max_chars] : value.as_json }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Newest first: by arrival when the table has that column, then by primary key.
|
|
65
|
+
def arrival_order
|
|
66
|
+
order = model.column_names.include?(arrived_at_column) ? { arrived_at_column => :desc } : {}
|
|
67
|
+
order.merge(model.primary_key => :desc)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def encrypted_fields
|
|
71
|
+
Array(model.try(:encrypted_attributes)).map(&:to_s) & fields
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate!
|
|
75
|
+
raise DefinitionError, "#{model.name}: declare the fields Jev reads with `reads`" if fields.empty?
|
|
76
|
+
|
|
77
|
+
check_columns([ tenant_column, *fields, *Array(keyword).grep(String), *supplied_labels.flat_map(&:watch) ].compact.uniq)
|
|
78
|
+
check_embeddings if embeddings
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
DEFAULT_RANKING = { label: 1.0, text: 1.0, keyword: 0.5, exact: 1.0, min_similarity: 0.0 }.freeze
|
|
82
|
+
DEFAULT_WEAK_BELOW = 3
|
|
83
|
+
|
|
84
|
+
attr_writer :ranking, :weak_below
|
|
85
|
+
|
|
86
|
+
# KTD20 blend weights for keystroke scoring, tuned by the benchmark (R36).
|
|
87
|
+
def ranking
|
|
88
|
+
DEFAULT_RANKING.merge(@ranking || {})
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Fewer keystroke results than this count as weak (R19, R21).
|
|
92
|
+
def weak_below
|
|
93
|
+
@weak_below || DEFAULT_WEAK_BELOW
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def check_columns(names)
|
|
99
|
+
columns = model.attribute_names
|
|
100
|
+
missing = names.reject { |name| columns.include?(name) || model.method_defined?(name) }
|
|
101
|
+
raise DefinitionError, "#{model.name}: unknown attributes #{missing.join(', ')}" if missing.any?
|
|
102
|
+
rescue ActiveRecord::ActiveRecordError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def check_embeddings
|
|
107
|
+
return if embeddings.key?(:column) || embeddings[:allow_encrypted] || encrypted_fields.empty?
|
|
108
|
+
|
|
109
|
+
raise DefinitionError, "#{model.name}: embeddings would send encrypted fields #{encrypted_fields.join(', ')} " \
|
|
110
|
+
"to the embedding provider; pass allow_encrypted: true to opt in"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The block API inside `truffler do ... end`.
|
|
114
|
+
class DSL
|
|
115
|
+
def initialize(definition)
|
|
116
|
+
@definition = definition
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def tenant(column)
|
|
120
|
+
@definition.tenant_column = column.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def reads(*fields)
|
|
124
|
+
@definition.fields.concat(fields.map(&:to_s))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def label(key, type, **options)
|
|
128
|
+
@definition.add_label(LabelDefinition.new(key, type, **options))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def keyword(*columns_or_callable)
|
|
132
|
+
callable = columns_or_callable.first if columns_or_callable.one? && columns_or_callable.first.respond_to?(:call)
|
|
133
|
+
@definition.keyword = callable || columns_or_callable.map(&:to_s)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def exact(name, callable)
|
|
137
|
+
@definition.exact_sources[name.to_s] = callable
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def embeddings(column: nil, **options)
|
|
141
|
+
@definition.embeddings = column ? { column: column.to_s } : DEFAULT_EMBEDDINGS.merge(options)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def provider(name, label:, search:)
|
|
145
|
+
@definition.providers[name.to_s] = { label: label, search: search }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def order(column, direction = :desc)
|
|
149
|
+
@definition.order = [ column.to_s, direction.to_sym ]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def surface(name, explicit_action: :enter)
|
|
153
|
+
unless EXPLICIT_ACTIONS.include?(explicit_action)
|
|
154
|
+
raise DefinitionError, "surface #{name}: explicit_action must be one of #{EXPLICIT_ACTIONS.join(', ')}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@definition.surfaces[name.to_s] = { explicit_action: explicit_action }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def arrived_at(column)
|
|
161
|
+
@definition.arrived_at_column = column.to_s
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def ranking(**weights)
|
|
165
|
+
unknown = weights.keys - DEFAULT_RANKING.keys
|
|
166
|
+
raise DefinitionError, "ranking: unknown weights #{unknown.join(', ')}" if unknown.any?
|
|
167
|
+
|
|
168
|
+
@definition.ranking = weights.transform_values { |weight| Float(weight) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def weak_below(count)
|
|
172
|
+
@definition.weak_below = Integer(count)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Embeddings
|
|
3
|
+
# Finds records whose embedding is missing or was made under another
|
|
4
|
+
# fingerprint (model, width, or fields changed) and enqueues EmbedJob for
|
|
5
|
+
# them, newest first, `batch_size` jobs at a time behind an id cursor.
|
|
6
|
+
# ResumeJob runs a bounded pass on every sweep; hosts call `enqueue` with
|
|
7
|
+
# no limit after enabling embeddings or changing the model, width, or
|
|
8
|
+
# fields.
|
|
9
|
+
class Backfill
|
|
10
|
+
BATCH_SIZE = 1_000
|
|
11
|
+
|
|
12
|
+
attr_reader :model
|
|
13
|
+
|
|
14
|
+
def initialize(model)
|
|
15
|
+
@model = model
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def stale_ids(limit: nil, before: nil)
|
|
19
|
+
definition = model.truffler_definition
|
|
20
|
+
return [] unless Embeddings.managed?(definition)
|
|
21
|
+
|
|
22
|
+
current = Records::RecordState.for_model(model).where(embedding_fingerprint: Embeddings.fingerprint(definition))
|
|
23
|
+
.where.not(embedded_at: nil).select(:record_id)
|
|
24
|
+
scope = model.where.not(model.primary_key => current)
|
|
25
|
+
scope = scope.where(model.primary_key => ...before) if before
|
|
26
|
+
scope.order(model.primary_key => :desc).limit(limit).pluck(model.primary_key)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def enqueue(limit: nil, batch_size: BATCH_SIZE)
|
|
30
|
+
count = 0
|
|
31
|
+
cursor = nil
|
|
32
|
+
loop do
|
|
33
|
+
take = limit ? [ batch_size, limit - count ].min : batch_size
|
|
34
|
+
break unless take.positive?
|
|
35
|
+
|
|
36
|
+
ids = stale_ids(limit: take, before: cursor)
|
|
37
|
+
break if ids.empty?
|
|
38
|
+
|
|
39
|
+
ActiveJob.perform_all_later(ids.map { |id| Jobs::EmbedJob.new(model.polymorphic_name, id) })
|
|
40
|
+
count += ids.size
|
|
41
|
+
cursor = ids.last
|
|
42
|
+
break if ids.size < take
|
|
43
|
+
end
|
|
44
|
+
count
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Embeddings
|
|
3
|
+
# Searches a vector column the host already maintains (R11). The gem never
|
|
4
|
+
# writes it and never calls the embedder for it. Similarity runs in SQL
|
|
5
|
+
# when the column is pgvector or sqlite-vec is loaded, and otherwise as
|
|
6
|
+
# exact cosine in Ruby over the tenant's rows (arrays, JSON text, or
|
|
7
|
+
# float32 blobs).
|
|
8
|
+
class ColumnStore < VectorStore
|
|
9
|
+
attr_reader :column
|
|
10
|
+
|
|
11
|
+
def initialize(column)
|
|
12
|
+
@column = column.to_s
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def write(model, *)
|
|
16
|
+
raise Error, "#{model.name} searches its own #{column} column; truffler does not write it"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def nearest(model, tenant_key:, vector:, k: DEFAULT_K)
|
|
20
|
+
scope = records(model, tenant_key)
|
|
21
|
+
if (dialect = dialect(model))
|
|
22
|
+
distance = NeighborStore.distance_sql(dialect, model.connection, column_sql(model), vector)
|
|
23
|
+
scope.order(Arel.sql(distance)).limit(k).pluck(model.primary_key, Arel.sql("1 - #{distance}"))
|
|
24
|
+
.map { |id, similarity| [ id, similarity.to_f ] }
|
|
25
|
+
else
|
|
26
|
+
scope.pluck(model.primary_key, column).filter_map do |id, value|
|
|
27
|
+
stored = Records::Embedding.unpack(value)
|
|
28
|
+
[ id, self.class.cosine(vector, stored) ] if stored.size == vector.size
|
|
29
|
+
end.max_by(k) { |_, similarity| similarity }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def similarity_sql(model, tenant_key:, vector:, k: DEFAULT_K)
|
|
34
|
+
dialect = dialect(model)
|
|
35
|
+
return super unless dialect
|
|
36
|
+
|
|
37
|
+
check_tenant!(model, tenant_key)
|
|
38
|
+
Arel.sql("COALESCE(1 - #{NeighborStore.distance_sql(dialect, model.connection, column_sql(model), vector)}, 0.0)")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def inline_sql?(model)
|
|
42
|
+
!dialect(model).nil?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def records(model, tenant_key)
|
|
48
|
+
check_tenant!(model, tenant_key)
|
|
49
|
+
definition = model.truffler_definition
|
|
50
|
+
scope = model.where.not(column => nil)
|
|
51
|
+
definition.scoped? ? scope.where(definition.tenant_column => tenant_key) : scope
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def dialect(model)
|
|
55
|
+
return @dialect if defined?(@dialect)
|
|
56
|
+
|
|
57
|
+
@dialect = NeighborStore.dialect(model.connection, table: model.table_name, column: column)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def column_sql(model)
|
|
61
|
+
"#{model.connection.quote_table_name(model.table_name)}.#{model.connection.quote_column_name(column)}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Embeddings
|
|
3
|
+
# The embedder seam. `embed(texts, model:, dimensions:)` returns one vector
|
|
4
|
+
# per text plus usage, and emits `truffler.embed_call` with tokens, cost,
|
|
5
|
+
# and latency but never the texts. Subclasses implement `perform`,
|
|
6
|
+
# returning {vectors:, model:, input_tokens:}; a missing token count is
|
|
7
|
+
# estimated and flagged.
|
|
8
|
+
class Embedder
|
|
9
|
+
Result = Data.define(:vectors, :model, :input_tokens, :tokens_estimated, :cost)
|
|
10
|
+
|
|
11
|
+
def embed(texts, model:, dimensions:)
|
|
12
|
+
texts = Array(texts)
|
|
13
|
+
started = Instrumentation.monotonic_ms
|
|
14
|
+
payload = { model: model, record_count: texts.size }
|
|
15
|
+
|
|
16
|
+
response = perform(texts, model: model, dimensions: dimensions)
|
|
17
|
+
result = result_for(response, texts, model)
|
|
18
|
+
payload.merge!(model: result.model, input_tokens: result.input_tokens, tokens_estimated: result.tokens_estimated,
|
|
19
|
+
cost: result.cost)
|
|
20
|
+
result
|
|
21
|
+
rescue Truffler::Error => error
|
|
22
|
+
payload[:error_class] = error.class.name
|
|
23
|
+
raise
|
|
24
|
+
rescue StandardError => error
|
|
25
|
+
wrapped = ClientError.from(error)
|
|
26
|
+
payload.merge!(error_class: wrapped.error_class, status: wrapped.status)
|
|
27
|
+
raise wrapped, cause: nil
|
|
28
|
+
ensure
|
|
29
|
+
Instrumentation.instrument(:embed_call, payload.merge(latency_ms: Instrumentation.monotonic_ms - started))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def perform(texts, model:, dimensions:)
|
|
33
|
+
raise NotImplementedError, "#{self.class.name}#perform"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def result_for(response, texts, model)
|
|
39
|
+
vectors = response.fetch(:vectors)
|
|
40
|
+
unless vectors.size == texts.size
|
|
41
|
+
raise IncompleteAnswers, "embedder returned #{vectors.size} vectors for #{texts.size} texts"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
tokens = response[:input_tokens]
|
|
45
|
+
estimated = tokens.nil?
|
|
46
|
+
tokens = estimated ? texts.sum { |text| Tokens.estimate(text) } : tokens.to_i
|
|
47
|
+
cost = tokens * Truffler.config.embedding_cost_per_million_tokens / 1_000_000.0
|
|
48
|
+
Result.new(vectors: vectors.map { |vector| vector.map(&:to_f) }, model: response[:model] || model,
|
|
49
|
+
input_tokens: tokens, tokens_estimated: estimated, cost: cost)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|