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,67 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Providers
|
|
3
|
+
# Calls the declared provider for one run, scoped to the searching
|
|
4
|
+
# user's account, and stores exactly one terminal section state:
|
|
5
|
+
# :results, :empty, or :unavailable. A cancelled run is left alone.
|
|
6
|
+
class Runner
|
|
7
|
+
def initialize(run, tenant_key:, user_key:)
|
|
8
|
+
@run = run
|
|
9
|
+
@tenant_key = tenant_key
|
|
10
|
+
@user_key = user_key
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
return if cancelled?
|
|
15
|
+
|
|
16
|
+
provider = Providers.declared(@run.model.truffler_definition)
|
|
17
|
+
return unless provider
|
|
18
|
+
|
|
19
|
+
started = Instrumentation.monotonic_ms
|
|
20
|
+
state = search(provider)
|
|
21
|
+
return if cancelled?
|
|
22
|
+
|
|
23
|
+
@run.update_section(SECTION, state)
|
|
24
|
+
instrument(state, started)
|
|
25
|
+
state
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def search(provider)
|
|
31
|
+
return Providers.state(provider, :unavailable, reason: :scope_mismatch) unless scope_matches?
|
|
32
|
+
|
|
33
|
+
text = query_text
|
|
34
|
+
return Providers.state(provider, :unavailable, reason: :no_query) if text.blank?
|
|
35
|
+
|
|
36
|
+
results = Array.wrap(provider.search.call(text, tenant: @tenant_key, user: @user_key))
|
|
37
|
+
results.empty? ? Providers.state(provider, :empty) : Providers.state(provider, :results, results: results)
|
|
38
|
+
rescue StandardError => error
|
|
39
|
+
Providers.state(provider, :unavailable, error_class: error.class.name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The job's keys come from the run's creator; a run that knows its own
|
|
43
|
+
# keys must agree, so one account's provider never answers another's run.
|
|
44
|
+
def scope_matches?
|
|
45
|
+
%i[tenant_key user_key].all? do |key|
|
|
46
|
+
!@run.respond_to?(key) || @run.public_send(key)&.to_s == instance_variable_get("@#{key}")&.to_s
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def query_text
|
|
51
|
+
query = @run.query
|
|
52
|
+
(query.respond_to?(:raw) ? query.raw : query.to_s).strip
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cancelled?
|
|
56
|
+
@run.respond_to?(:cancelled?) && @run.cancelled?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def instrument(state, started)
|
|
60
|
+
Instrumentation.instrument("provider_search", run_id: @run.id.to_s, record_type: @run.model.polymorphic_name,
|
|
61
|
+
tenant_key: @tenant_key, user_key: @user_key, section: SECTION.to_s, status: state[:status],
|
|
62
|
+
reason: state[:reason], result_count: Array(state[:results]).size, error_class: state[:error_class],
|
|
63
|
+
latency_ms: Instrumentation.elapsed_ms(started))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# Provider backup search (R19): on the explicit action, a host-declared
|
|
3
|
+
# provider (Gmail for Cora) runs when the query asks for exact text or the
|
|
4
|
+
# local results are weak, and its state renders in its own section below
|
|
5
|
+
# every local section.
|
|
6
|
+
#
|
|
7
|
+
# The run passed to `start` and returned by `run_finder` is duck-typed. It
|
|
8
|
+
# must respond to:
|
|
9
|
+
#
|
|
10
|
+
# id -> the run id, the only run reference jobs carry
|
|
11
|
+
# model -> the searched ActiveRecord class
|
|
12
|
+
# query -> the raw query text (a String or Search::Query),
|
|
13
|
+
# decrypted by the run on encrypted models
|
|
14
|
+
# update_section(:provider, state)
|
|
15
|
+
# -> stores the section state and pings the host
|
|
16
|
+
# (`section: provider`); called once per transition
|
|
17
|
+
# candidate_ids -> the local candidate snapshot, used for the weak
|
|
18
|
+
# check when `start` gets neither `local_result:`
|
|
19
|
+
# nor a run that answers `local_weak?`
|
|
20
|
+
#
|
|
21
|
+
# and may respond to `local_weak?` (the keystroke's own weak verdict, which
|
|
22
|
+
# the weak check prefers over `candidate_ids`), `cancelled?`, `tenant_key`,
|
|
23
|
+
# and `user_key`, which the job honors when present.
|
|
24
|
+
module Providers
|
|
25
|
+
SECTION = :provider
|
|
26
|
+
STATUSES = %i[pending results empty unavailable].freeze
|
|
27
|
+
|
|
28
|
+
mattr_accessor :run_finder, default: ->(run_id) { SmartSearch::Run.load(run_id) }
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Returns :exact_text or :weak_local when the provider was enqueued, nil
|
|
33
|
+
# when it does not run. `local_result` is the keystroke Search::Result
|
|
34
|
+
# for the same query; its invite row carries the model's weak definition.
|
|
35
|
+
def start(run, query:, tenant_key:, user_key:, local_result: nil)
|
|
36
|
+
Backup.new(run, query: query, tenant_key: tenant_key, user_key: user_key, local_result: local_result).start
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def find_run(run_id)
|
|
40
|
+
run_finder.call(run_id)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The first declared provider, or nil.
|
|
44
|
+
def declared(definition)
|
|
45
|
+
name, options = definition.providers.first
|
|
46
|
+
Provider.new(name, options[:label], options[:search]) if name
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def state(provider, status, **details)
|
|
50
|
+
raise ArgumentError, "unknown provider status #{status}" unless STATUSES.include?(status)
|
|
51
|
+
|
|
52
|
+
{ status: status, name: provider.name, label: provider.label }.merge(details)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Provider = Struct.new(:name, :label, :search)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module QueryEncoding
|
|
3
|
+
# Adds the in-flight side of query encoding to U8's encoding cache: a
|
|
4
|
+
# marker written with `unless_exist` so one job runs per query, and the
|
|
5
|
+
# pending query payload the job reads (encrypted on encrypted models).
|
|
6
|
+
# Both live only while the job is in flight; encodings and vectors are
|
|
7
|
+
# written through `Search::EncodingCache` and never hold query text.
|
|
8
|
+
class Cache
|
|
9
|
+
delegate :key, :vector_key, :read, :write, :read_vector, :write_vector, to: :@encodings
|
|
10
|
+
|
|
11
|
+
def initialize(store: Truffler.config.cache_store)
|
|
12
|
+
@store = store
|
|
13
|
+
@encodings = Search::EncodingCache.new(store: store)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def in_flight_key(cache_key)
|
|
17
|
+
"#{cache_key}/in_flight"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def payload_key(cache_key)
|
|
21
|
+
"#{cache_key}/payload"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# True when this caller took the marker, false when a job already holds it.
|
|
25
|
+
def claim(cache_key, expires_in: IN_FLIGHT_TTL)
|
|
26
|
+
@store.write(in_flight_key(cache_key), true, unless_exist: true, expires_in: expires_in).present?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def in_flight?(cache_key)
|
|
30
|
+
@store.exist?(in_flight_key(cache_key))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def release(cache_key)
|
|
34
|
+
@store.delete(payload_key(cache_key))
|
|
35
|
+
@store.delete(in_flight_key(cache_key))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def encoded?(cache_key)
|
|
39
|
+
@store.exist?(cache_key)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def read_encoding(cache_key, query)
|
|
43
|
+
Search::Encoding.load(@store.read(cache_key), query || Search::Query.new(""))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns false when the query cannot be stored safely: an encrypted
|
|
47
|
+
# model without ActiveRecord encryption configured.
|
|
48
|
+
def write_payload(cache_key, model, query, tenant_key:, user_key:, expires_in: IN_FLIGHT_TTL)
|
|
49
|
+
encrypted = Misses.encrypted_model?(model)
|
|
50
|
+
return false if encrypted && !Misses.encryption_configured?
|
|
51
|
+
|
|
52
|
+
@store.write(payload_key(cache_key), { "record_type" => model.polymorphic_name, "tenant_key" => tenant_key,
|
|
53
|
+
"user_key" => user_key, "query" => Misses.seal(model, query.normalized), "encrypted" => encrypted }, expires_in: expires_in)
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# {model:, query:, tenant_key:, user_key:} or nil once expired, released,
|
|
58
|
+
# or undecryptable.
|
|
59
|
+
def read_payload(cache_key)
|
|
60
|
+
payload = @store.read(payload_key(cache_key))
|
|
61
|
+
model = payload && payload["record_type"].safe_constantize
|
|
62
|
+
return unless model.try(:truffler_definition)
|
|
63
|
+
|
|
64
|
+
text = payload["encrypted"] ? ActiveRecord::Encryption.encryptor.decrypt(payload["query"]) : payload["query"]
|
|
65
|
+
{ model: model, query: Search::Query.new(text), tenant_key: payload["tenant_key"], user_key: payload["user_key"] }
|
|
66
|
+
rescue ActiveRecord::Encryption::Errors::Base
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module QueryEncoding
|
|
3
|
+
# Encodes one pending query (KTD9). It asks a fixed question set: each
|
|
4
|
+
# label gets `filter | boost | ignore`, each choice label also gets its
|
|
5
|
+
# options plus `none`, and each of the first 12 word tokens gets
|
|
6
|
+
# `keyword | label_term | filler`. Exact-text tokens (digits, dates,
|
|
7
|
+
# quoted phrases, emails, identifiers) are keywords decided locally and
|
|
8
|
+
# never asked (R18). Query text travels only in `state` ("query" and
|
|
9
|
+
# "tokens"); a token question names its word by position, `tokens[n]`,
|
|
10
|
+
# so searcher text never lands in an instruction (R8).
|
|
11
|
+
#
|
|
12
|
+
# Answers become a `Search::Encoding` with the KTD20 intent vector: boost
|
|
13
|
+
# gives the declared boost, filter narrows and adds `filter_weight`
|
|
14
|
+
# (default 0), ignore gives nothing. The encoding is cached whenever it
|
|
15
|
+
# lands, late or not (AE6). With gem-managed embeddings the query vector
|
|
16
|
+
# is embedded and cached in the same pass.
|
|
17
|
+
class Encoder
|
|
18
|
+
INTENTS = {
|
|
19
|
+
"filter" => "The query asks only for records where this holds",
|
|
20
|
+
"boost" => "The query prefers records where this holds but does not require it",
|
|
21
|
+
"ignore" => "The query does not mention this"
|
|
22
|
+
}.freeze
|
|
23
|
+
TOKEN_ROLES = {
|
|
24
|
+
"keyword" => "A word to match in the record text",
|
|
25
|
+
"label_term" => "A word that names one of the labels above rather than text to match",
|
|
26
|
+
"filler" => "A word that carries no meaning for the search"
|
|
27
|
+
}.freeze
|
|
28
|
+
NO_OPTION = "none".freeze
|
|
29
|
+
|
|
30
|
+
Request = Data.define(:state, :questions, :token_ids, :exact_tokens, :unasked_tokens)
|
|
31
|
+
|
|
32
|
+
attr_reader :client, :budget, :cache
|
|
33
|
+
|
|
34
|
+
def initialize(client: Truffler.config.client, budget: Budget.new, cache: Cache.new,
|
|
35
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, sleeper: ->(seconds) { sleep(seconds) })
|
|
36
|
+
@client = client
|
|
37
|
+
@budget = budget
|
|
38
|
+
@cache = cache
|
|
39
|
+
@clock = clock
|
|
40
|
+
@sleeper = sleeper
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def request(model, query, tenant_key:, user_key: nil)
|
|
44
|
+
labels = labels(model, tenant_key, user_key)
|
|
45
|
+
questions = Questions.new
|
|
46
|
+
labels.each_value do |label|
|
|
47
|
+
questions.choice(:"intent__#{label.question_key}", instructions: intent_instructions(label), criteria: INTENTS)
|
|
48
|
+
end
|
|
49
|
+
labels.each_value do |label|
|
|
50
|
+
next unless label.type == :choice
|
|
51
|
+
|
|
52
|
+
options = label.options(tenant_key).merge(NO_OPTION => "The query names none of these")
|
|
53
|
+
questions.choice(:"option__#{label.question_key}", instructions: %(Which "#{label.key}" option does the search query ask about?),
|
|
54
|
+
criteria: options)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
words = query.tokens.each_with_index.reject { |token, _| query.exact_tokens.include?(token) }
|
|
58
|
+
asked = words.first(MAX_TOKEN_QUESTIONS)
|
|
59
|
+
token_ids = asked.to_h do |_token, position|
|
|
60
|
+
id = :"token__#{position}"
|
|
61
|
+
questions.choice(id, instructions: %(In the search query, what is the word tokens[#{position}]?), criteria: TOKEN_ROLES)
|
|
62
|
+
[ position, id.to_s ]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Request.new(state: { "query" => query.normalized, "tokens" => query.tokens }, questions: questions.to_h, token_ids: token_ids,
|
|
66
|
+
exact_tokens: query.exact_tokens, unasked_tokens: words.drop(MAX_TOKEN_QUESTIONS).map(&:first))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Encodes the query pending under `cache_key`. Returns the encoding, or
|
|
70
|
+
# nil when the payload expired, the vocabulary moved on, or the encode
|
|
71
|
+
# budget was denied (a silent skip). Always releases the in-flight marker.
|
|
72
|
+
def encode(cache_key)
|
|
73
|
+
pending = cache.read_payload(cache_key)
|
|
74
|
+
return unless pending
|
|
75
|
+
|
|
76
|
+
model, query, tenant_key, user_key = pending.values_at(:model, :query, :tenant_key, :user_key)
|
|
77
|
+
return unless cache.key(model, query, tenant_key: tenant_key, user_key: user_key) == cache_key
|
|
78
|
+
|
|
79
|
+
encoding = cache.encoded?(cache_key) ? cache.read_encoding(cache_key, query) : encode_labels(model, query, tenant_key, user_key)
|
|
80
|
+
embed_query(model, query, tenant_key)
|
|
81
|
+
encoding
|
|
82
|
+
ensure
|
|
83
|
+
cache.release(cache_key)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Polls the cache until the encoding lands or the deadline (seconds
|
|
87
|
+
# from now) passes; nil at the deadline. Pass `query:` to recover the
|
|
88
|
+
# keyword and label-term tokens; otherwise the pending payload supplies it.
|
|
89
|
+
def await(cache_key, deadline:, query: nil, interval: 0.05)
|
|
90
|
+
stop = @clock.call + deadline.to_f
|
|
91
|
+
query ||= cache.read_payload(cache_key)&.dig(:query)
|
|
92
|
+
loop do
|
|
93
|
+
return cache.read_encoding(cache_key, query) if cache.encoded?(cache_key)
|
|
94
|
+
|
|
95
|
+
remaining = stop - @clock.call
|
|
96
|
+
return if remaining <= 0
|
|
97
|
+
|
|
98
|
+
@sleeper.call([ interval, remaining ].min)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def encoding_for(model, request, answers, tenant_key:, user_key: nil)
|
|
103
|
+
filters = {}
|
|
104
|
+
boosts = {}
|
|
105
|
+
intent = {}
|
|
106
|
+
labels(model, tenant_key, user_key).each_value do |label|
|
|
107
|
+
key = storage_key(label, answers, tenant_key)
|
|
108
|
+
next unless key
|
|
109
|
+
|
|
110
|
+
case answers.choice("intent__#{label.question_key}")
|
|
111
|
+
when "filter"
|
|
112
|
+
filters[key] = label.filter_at || DEFAULT_FILTER_AT
|
|
113
|
+
intent[key] = label.filter_weight
|
|
114
|
+
when "boost"
|
|
115
|
+
boosts[key] = intent[key] = label.boost || DEFAULT_BOOST
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
roles = request.token_ids.transform_values { |id| answers.choice(id) }
|
|
120
|
+
query = Search::Query.new(request.state["query"])
|
|
121
|
+
keyword_tokens = query.tokens.each_with_index.filter_map do |token, position|
|
|
122
|
+
token if roles.fetch(position, "keyword") == "keyword"
|
|
123
|
+
end
|
|
124
|
+
label_term_tokens = roles.filter_map { |position, role| query.tokens[position] if role == "label_term" }
|
|
125
|
+
Search::Encoding.new(filters: filters, boosts: boosts, intent_vector: intent, keyword_tokens: keyword_tokens,
|
|
126
|
+
label_term_tokens: label_term_tokens)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def encode_labels(model, query, tenant_key, user_key)
|
|
132
|
+
return if labels(model, tenant_key, user_key).empty?
|
|
133
|
+
|
|
134
|
+
started = Instrumentation.monotonic_ms
|
|
135
|
+
decision = budget.acquire(priority: :encode, user_key: user_key)
|
|
136
|
+
if decision.denied?
|
|
137
|
+
instrument(model, tenant_key, started, outcome: "skipped", reason: decision.reason)
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
request = request(model, query, tenant_key: tenant_key, user_key: user_key)
|
|
142
|
+
answers = client.ask(state: request.state, questions: request.questions, priority: decision.priority)
|
|
143
|
+
encoding = encoding_for(model, request, answers, tenant_key: tenant_key, user_key: user_key)
|
|
144
|
+
cache.write(model, query, encoding, tenant_key: tenant_key, user_key: user_key)
|
|
145
|
+
Misses.hook.call(model, tenant_key: tenant_key, user_key: user_key, query: query.normalized) if encoding.empty?
|
|
146
|
+
instrument(model, tenant_key, started, outcome: encoding.empty? ? "empty" : "encoded", question_count: request.questions.size,
|
|
147
|
+
filter_count: encoding.filters.size, boost_count: encoding.intent_vector.size)
|
|
148
|
+
encoding
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def embed_query(model, query, tenant_key)
|
|
152
|
+
definition = model.truffler_definition
|
|
153
|
+
return unless Embeddings.managed?(definition) && cache.read_vector(model, query, tenant_key: tenant_key).nil?
|
|
154
|
+
|
|
155
|
+
settings = definition.embeddings
|
|
156
|
+
vector = Embeddings.embedder.embed([ query.normalized ], model: settings[:model], dimensions: settings[:dimensions]).vectors.first
|
|
157
|
+
cache.write_vector(model, query, vector, tenant_key: tenant_key)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def labels(model, tenant_key, user_key)
|
|
161
|
+
model.truffler_definition.vocabulary.labels_for(tenant_key: tenant_key, user_key: user_key)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The label's storage key the query names, or nil for a choice label
|
|
165
|
+
# whose option answer is `none`.
|
|
166
|
+
def storage_key(label, answers, tenant_key)
|
|
167
|
+
return label.key unless label.type == :choice
|
|
168
|
+
|
|
169
|
+
option = answers.choice("option__#{label.question_key}")
|
|
170
|
+
"#{label.key}:#{option}" if option != NO_OPTION && label.options(tenant_key).key?(option)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def intent_instructions(label)
|
|
174
|
+
%(How does the search query use the label "#{label.key}" (#{label.description})?)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def instrument(model, tenant_key, started, **payload)
|
|
178
|
+
Instrumentation.instrument("encode", { record_type: model.polymorphic_name, tenant_key: tenant_key,
|
|
179
|
+
latency_ms: Instrumentation.elapsed_ms(started) }.merge(payload))
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module QueryEncoding
|
|
3
|
+
# The default `config.encoding_prefetch`. Called by keystroke search on a
|
|
4
|
+
# cache miss; enqueues one `EncodeQueryJob(cache_key)` per in-flight window
|
|
5
|
+
# and returns true while an encoding is in flight. It never calls Jev.
|
|
6
|
+
class Prefetch
|
|
7
|
+
def initialize(cache: nil)
|
|
8
|
+
@cache = cache
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(model, query, cache_key:, tenant_key:, user_key:)
|
|
12
|
+
cache = @cache || Cache.new
|
|
13
|
+
return false unless work?(cache, model, query, cache_key, tenant_key)
|
|
14
|
+
return true unless cache.claim(cache_key)
|
|
15
|
+
|
|
16
|
+
unless cache.write_payload(cache_key, model, query, tenant_key: tenant_key, user_key: user_key)
|
|
17
|
+
cache.release(cache_key)
|
|
18
|
+
return false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Jobs::EncodeQueryJob.perform_later(cache_key)
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def work?(cache, model, query, cache_key, tenant_key)
|
|
28
|
+
definition = model.truffler_definition
|
|
29
|
+
(definition.labels.any? && !cache.encoded?(cache_key)) ||
|
|
30
|
+
(Embeddings.managed?(definition) && cache.read_vector(model, query, tenant_key: tenant_key).nil?)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# Query encoding (KTD9, KTD10): Jev turns a query into label filters,
|
|
3
|
+
# boosts, a sparse intent vector, and keyword splits. Keystroke search only
|
|
4
|
+
# reads the cached result; a miss hands the query to `Prefetch`, which
|
|
5
|
+
# enqueues one `EncodeQueryJob` per in-flight window.
|
|
6
|
+
module QueryEncoding
|
|
7
|
+
IN_FLIGHT_TTL = 2.minutes
|
|
8
|
+
DEFAULT_FILTER_AT = 0.5
|
|
9
|
+
DEFAULT_BOOST = 1.0
|
|
10
|
+
MAX_TOKEN_QUESTIONS = 12
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# Builds TypeSafe questions as plain wire-shape hashes keyed by string id:
|
|
3
|
+
#
|
|
4
|
+
# Truffler::Questions.build do |q|
|
|
5
|
+
# q.noul :spam, instructions: "Is this spam?", criteria: { true => "...", false => "..." }
|
|
6
|
+
# q.choice :tone, instructions: "Tone?", criteria: { calm: nil, angry: "Hostile words" }
|
|
7
|
+
# q.score :urgency, instructions: "How urgent?", criteria: [ "Whenever", "Today" ]
|
|
8
|
+
# end
|
|
9
|
+
class Questions
|
|
10
|
+
ID = /\A[a-z0-9_]+\z/
|
|
11
|
+
SEPARATOR = "__".freeze
|
|
12
|
+
|
|
13
|
+
def self.build
|
|
14
|
+
new.tap { |questions| yield questions }.to_h
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.tag(prefix, index)
|
|
18
|
+
format("%s%03d", prefix, index)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.tagged_id(tag, key)
|
|
22
|
+
"#{tag}#{SEPARATOR}#{key}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.split_id(id)
|
|
26
|
+
id.to_s.split(SEPARATOR, 2)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.valid_id?(id)
|
|
30
|
+
ID.match?(id.to_s)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@questions = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def noul(id, instructions:, criteria: nil)
|
|
38
|
+
criteria = criteria&.to_h { |answer, description| [ answer.to_s, description ] }
|
|
39
|
+
if criteria && !(criteria.keys - %w[true false]).empty?
|
|
40
|
+
raise ArgumentError, "#{id}: noul criteria keys must be true and false"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
add(id, "noul", instructions, criteria)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def choice(id, instructions:, criteria:)
|
|
47
|
+
criteria = criteria.to_h { |option| [ option, nil ] } if criteria.is_a?(Array)
|
|
48
|
+
raise ArgumentError, "#{id}: choice criteria must name at least one option" if criteria.blank?
|
|
49
|
+
|
|
50
|
+
add(id, "choice", instructions, criteria.to_h { |option, description| [ option.to_s, description ] })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def score(id, instructions:, criteria:)
|
|
54
|
+
levels = criteria.is_a?(Hash) ? criteria.sort_by { |level, _| level.to_i }.map(&:last) : Array(criteria)
|
|
55
|
+
raise ArgumentError, "#{id}: score criteria need at least two ordered levels" if levels.size < 2
|
|
56
|
+
|
|
57
|
+
add(id, "score", instructions, levels)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
@questions.deep_dup
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def add(id, type, instructions, criteria)
|
|
67
|
+
key = id.to_s
|
|
68
|
+
raise ArgumentError, "question id #{id.inspect} must match #{ID.source}" unless self.class.valid_id?(key)
|
|
69
|
+
raise ArgumentError, "question id #{key} is already defined" if @questions.key?(key)
|
|
70
|
+
raise ArgumentError, "#{key}: instructions are required" if instructions.blank?
|
|
71
|
+
|
|
72
|
+
question = { "type" => type, "instructions" => Canonical.sort(instructions) }
|
|
73
|
+
question["criteria"] = criteria unless criteria.nil?
|
|
74
|
+
@questions[key] = question
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Records
|
|
3
|
+
# One row per record holding its optional text vector and its label vector
|
|
4
|
+
# (KTD20). Neither column holds anything derived from text except floats.
|
|
5
|
+
# Blobs are packed little-endian float32, the layout sqlite-vec reads.
|
|
6
|
+
class Embedding < ActiveRecord::Base
|
|
7
|
+
self.table_name = "truffler_embeddings"
|
|
8
|
+
|
|
9
|
+
scope :for_model, ->(model) { where(record_type: model.polymorphic_name) }
|
|
10
|
+
scope :with_vector, -> { where.not(embedding: nil) }
|
|
11
|
+
|
|
12
|
+
def self.pack(vector)
|
|
13
|
+
vector.map(&:to_f).pack("e*")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.unpack(value)
|
|
17
|
+
case value
|
|
18
|
+
when nil then nil
|
|
19
|
+
when Array then value.map(&:to_f)
|
|
20
|
+
else value.encoding == ::Encoding::BINARY ? value.unpack("e*") : JSON.parse(value).map(&:to_f)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The stored form for the `embedding` column: a blob, or pgvector's text
|
|
25
|
+
# literal when the install generator wrote a vector column.
|
|
26
|
+
def self.encode(vector)
|
|
27
|
+
columns_hash["embedding"].type == :binary ? pack(vector) : "[#{vector.map(&:to_f).join(',')}]"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def vector
|
|
31
|
+
self.class.unpack(embedding)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def label_values
|
|
35
|
+
self.class.unpack(label_vector)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Records
|
|
3
|
+
# One numeric answer per record and label key. Choice labels store one row
|
|
4
|
+
# per option as "label:option".
|
|
5
|
+
class Label < ActiveRecord::Base
|
|
6
|
+
self.table_name = "truffler_labels"
|
|
7
|
+
|
|
8
|
+
scope :for_label, ->(key) { where(label_key: key).or(where("label_key LIKE ?", "#{sanitize_sql_like(key)}:%")) }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Records
|
|
3
|
+
# A query whose encoding matched no label. Digests are keyed HMACs;
|
|
4
|
+
# `query_text` is normalized plaintext on plaintext models, AR-encryption
|
|
5
|
+
# ciphertext on encrypted models when it is configured, and null otherwise.
|
|
6
|
+
class QueryMiss < ActiveRecord::Base
|
|
7
|
+
self.table_name = "truffler_query_misses"
|
|
8
|
+
|
|
9
|
+
scope :for_model, ->(model) { where(record_type: model.polymorphic_name) }
|
|
10
|
+
scope :retained, ->(now = Time.current) { where(created_at: (now - Truffler.config.miss_retention)..) }
|
|
11
|
+
scope :expired, ->(now = Time.current) { where(created_at: ...(now - Truffler.config.miss_retention)) }
|
|
12
|
+
|
|
13
|
+
# The normalized query, decrypted on encrypted models; nil when no text
|
|
14
|
+
# was stored or it can no longer be decrypted.
|
|
15
|
+
def query
|
|
16
|
+
Misses.unseal(record_type.safe_constantize, query_text)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Records
|
|
3
|
+
# The durable labeling queue: one row per record, carrying its status,
|
|
4
|
+
# priority, attempts, and the vocabulary version it was labeled with.
|
|
5
|
+
class RecordState < ActiveRecord::Base
|
|
6
|
+
self.table_name = "truffler_record_states"
|
|
7
|
+
|
|
8
|
+
STATUSES = %w[pending labeling labeled failed].freeze
|
|
9
|
+
PRIORITIES = %w[live backfill].freeze
|
|
10
|
+
|
|
11
|
+
scope :for_model, ->(model) { where(record_type: model.polymorphic_name) }
|
|
12
|
+
|
|
13
|
+
# Only rows still claimed: a record edited mid-flight was reset to
|
|
14
|
+
# pending and must be labeled again.
|
|
15
|
+
def self.mark_supplied_failed(ids, error_class:)
|
|
16
|
+
now = Time.current
|
|
17
|
+
set = "attempts = attempts + 1, status = ?, priority = 'backfill', last_error_class = ?, claimed_at = NULL, updated_at = ?"
|
|
18
|
+
scope = where(id: ids)
|
|
19
|
+
scope.where("attempts + 1 >= ?", Truffler.config.max_attempts).update_all([ set, "failed", error_class, now ])
|
|
20
|
+
scope.where.not(status: "failed").update_all([ set, "pending", error_class, now ])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.mark_labeled(ids, version:)
|
|
24
|
+
now = Time.current
|
|
25
|
+
where(id: ids, status: "labeling").update_all(status: "labeled", vocabulary_version: version, labeled_at: now, attempts: 0,
|
|
26
|
+
last_error_class: nil, claimed_at: nil, updated_at: now)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# The privacy allowlist for anything the gem logs, instruments, or stores
|
|
3
|
+
# about a call: ids, counts, tokens, cost, model, latency, digests, and error
|
|
4
|
+
# class names. Every other key is dropped, so record and query text cannot
|
|
5
|
+
# leak through a payload by accident.
|
|
6
|
+
module Redaction
|
|
7
|
+
KEYS = %i[
|
|
8
|
+
priority model cost input_tokens tokens_estimated latency_ms error_class status outcome reason
|
|
9
|
+
record_type tenant_key user_key surface section sources vocabulary_version label_key
|
|
10
|
+
].to_set.freeze
|
|
11
|
+
SUFFIXES = %w[_id _ids _count _digest _ms].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def safe(payload)
|
|
16
|
+
payload.to_h.select { |key, _| allowed?(key) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def allowed?(key)
|
|
20
|
+
key = key.to_sym
|
|
21
|
+
KEYS.include?(key) || SUFFIXES.any? { |suffix| key.end_with?(suffix) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# Declared models by class name, so code reloading never pins stale classes.
|
|
3
|
+
class Registry
|
|
4
|
+
def initialize
|
|
5
|
+
@names = Set.new
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def register(model)
|
|
9
|
+
@names << model.name
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def models
|
|
13
|
+
@names.filter_map(&:safe_constantize)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|