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,69 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Lenses
|
|
3
|
+
# Checks lens questions against the KTD2 wire shape and Jev's limits
|
|
4
|
+
# before any Jev call: at most 10 score levels and 255 choice options.
|
|
5
|
+
module Validation
|
|
6
|
+
MAX_SCORE_LEVELS = 10
|
|
7
|
+
MAX_CHOICE_OPTIONS = 255
|
|
8
|
+
TYPES = %w[noul choice score].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# questions: {label_key => wire-shape question}; reused: label keys the
|
|
13
|
+
# lens borrows; available: keys it may borrow; declared: keys a new
|
|
14
|
+
# question must not shadow.
|
|
15
|
+
def validate!(questions, reused: [], available: nil, declared: [], max_questions: Lenses.settings.max_questions)
|
|
16
|
+
questions = questions.to_h
|
|
17
|
+
raise InvalidLens, "a lens needs at least one new or reused label" if questions.empty? && reused.blank?
|
|
18
|
+
raise InvalidLens, "a lens may add at most #{max_questions} questions" if questions.size > max_questions
|
|
19
|
+
|
|
20
|
+
questions.each { |key, question| check_question(key.to_s, question, declared) }
|
|
21
|
+
unknown = Array(reused).map(&:to_s) - Array(available || reused).map(&:to_s)
|
|
22
|
+
raise InvalidLens, "reused labels #{unknown.join(', ')} do not exist" if unknown.any?
|
|
23
|
+
|
|
24
|
+
questions
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def check_question(key, question, declared)
|
|
28
|
+
raise InvalidLens, "label #{key.inspect} must match #{LabelDefinition::KEY.source} without a double underscore" unless
|
|
29
|
+
LabelDefinition::KEY.match?(key) && !key.include?(Questions::SEPARATOR)
|
|
30
|
+
raise InvalidLens, "#{key} is already a declared label; reuse it instead" if declared.include?(key)
|
|
31
|
+
raise InvalidLens, "#{key}: question must be a hash" unless question.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
type = question["type"]
|
|
34
|
+
raise InvalidLens, "#{key}: type must be one of #{TYPES.join(', ')}" unless TYPES.include?(type)
|
|
35
|
+
raise InvalidLens, "#{key}: instructions are required" if question["instructions"].blank?
|
|
36
|
+
|
|
37
|
+
criteria = question["criteria"]
|
|
38
|
+
case type
|
|
39
|
+
when "noul" then check_noul(key, criteria)
|
|
40
|
+
when "choice" then check_choice(key, criteria)
|
|
41
|
+
when "score" then check_score(key, criteria)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def check_noul(key, criteria)
|
|
46
|
+
return if criteria.nil?
|
|
47
|
+
return if criteria.is_a?(Hash) && criteria.keys.map(&:to_s).sort == %w[false true]
|
|
48
|
+
|
|
49
|
+
raise InvalidLens, "#{key}: noul criteria keys must be true and false"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def check_choice(key, criteria)
|
|
53
|
+
raise InvalidLens, "#{key}: choice criteria must be a hash of options" unless criteria.is_a?(Hash) && criteria.any?
|
|
54
|
+
if criteria.size > MAX_CHOICE_OPTIONS
|
|
55
|
+
raise InvalidLens, "#{key}: #{criteria.size} choice options exceed Jev's limit of #{MAX_CHOICE_OPTIONS}"
|
|
56
|
+
end
|
|
57
|
+
raise InvalidLens, "#{key}: choice options must be named" if criteria.keys.any?(&:blank?)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def check_score(key, criteria)
|
|
61
|
+
raise InvalidLens, "#{key}: score criteria must list ordered levels" unless criteria.is_a?(Array)
|
|
62
|
+
raise InvalidLens, "#{key}: score labels need at least two levels" if criteria.size < 2
|
|
63
|
+
return if criteria.size <= MAX_SCORE_LEVELS
|
|
64
|
+
|
|
65
|
+
raise InvalidLens, "#{key}: #{criteria.size} score levels exceed Jev's limit of #{MAX_SCORE_LEVELS}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Lenses
|
|
3
|
+
# One drafted question set of a lens (R45). Versions are append-only:
|
|
4
|
+
# regenerating adds a draft, activating retires the previous active one,
|
|
5
|
+
# and restoring copies an earlier version forward as a new number.
|
|
6
|
+
class Version < ActiveRecord::Base
|
|
7
|
+
include SealedDescription
|
|
8
|
+
|
|
9
|
+
self.table_name = "truffler_lens_versions"
|
|
10
|
+
|
|
11
|
+
STATUSES = %w[draft active retired].freeze
|
|
12
|
+
|
|
13
|
+
belongs_to :lens, class_name: "Truffler::Lenses::Lens", inverse_of: :versions
|
|
14
|
+
serialize :questions, coder: JSON
|
|
15
|
+
serialize :reused_keys, coder: JSON
|
|
16
|
+
|
|
17
|
+
validates :status, inclusion: { in: STATUSES }
|
|
18
|
+
|
|
19
|
+
def draft? = status == "draft"
|
|
20
|
+
def active? = status == "active"
|
|
21
|
+
|
|
22
|
+
def to_draft
|
|
23
|
+
Draft.new(model: lens.model, scope: lens.scope, description: description, name: lens.name,
|
|
24
|
+
questions: questions.to_h, reused: Array(reused_keys), lens: lens)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def described_model
|
|
30
|
+
lens.model
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# Lenses (KTD21): LLM-drafted label questions stored as vocabulary
|
|
3
|
+
# extensions. Their answers live in `truffler_labels` under
|
|
4
|
+
# "lens:<lens_id>:<label>", so filters, label vectors, staleness, and
|
|
5
|
+
# backfill treat them like declared labels.
|
|
6
|
+
#
|
|
7
|
+
# `visible_questions` and `lens_fingerprints` are the seam the vocabulary
|
|
8
|
+
# and query encoding plug into: the active lens questions one searcher can
|
|
9
|
+
# see, and a digest that changes whenever that set changes.
|
|
10
|
+
module Lenses
|
|
11
|
+
KEY_PREFIX = "lens".freeze
|
|
12
|
+
|
|
13
|
+
Visibility = Data.define(:questions, :fingerprints, :lens_fingerprints, :lens_ids)
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def settings
|
|
18
|
+
Truffler.config.lenses
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def storage_prefix(lens_id)
|
|
22
|
+
"#{KEY_PREFIX}:#{lens_id}:"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def label_key(lens_id, label)
|
|
26
|
+
"#{storage_prefix(lens_id)}#{label}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Active lenses of this model visible to one searcher: app lenses, the
|
|
30
|
+
# tenant's lenses, and the user's own lenses in that tenant.
|
|
31
|
+
def visible_lenses(model, tenant_key:, user_key: nil, user_digest: nil)
|
|
32
|
+
user_digest ||= digest(user_key) if user_key.present?
|
|
33
|
+
tenant_key = tenant_key&.to_s
|
|
34
|
+
lenses = Lens.active.where(record_type: model.polymorphic_name)
|
|
35
|
+
visible = lenses.where(scope_type: "app").or(lenses.where(scope_type: "tenant", tenant_key: tenant_key))
|
|
36
|
+
visible = visible.or(lenses.where(scope_type: "user", tenant_key: tenant_key, scope_key: user_digest)) if user_digest
|
|
37
|
+
visible.order(:id).to_a
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Active lenses whose questions records of one tenant are labeled with:
|
|
41
|
+
# app lenses, the tenant's lenses, and every user's personal lenses in
|
|
42
|
+
# that tenant. Personal answers are stored per record like any other,
|
|
43
|
+
# and only their owner's searches can see them.
|
|
44
|
+
def labeling_lenses(model, tenant_key:)
|
|
45
|
+
lenses = Lens.active.where(record_type: model.polymorphic_name)
|
|
46
|
+
lenses.where(scope_type: "app").or(lenses.where(scope_type: %w[tenant user], tenant_key: tenant_key&.to_s)).order(:id).to_a
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# {"lens:<id>:<label>" => LensLabel} visible to one searcher, or with
|
|
50
|
+
# all_users: true, every label records of the tenant are labeled with.
|
|
51
|
+
def labels(model, tenant_key:, user_key: nil, all_users: false)
|
|
52
|
+
rows = active_rows(model, tenant_key)
|
|
53
|
+
unless all_users
|
|
54
|
+
personal = rows.select { |row| row["scope_type"] == "user" }
|
|
55
|
+
owner = digest(user_key) if personal.any? && user_key.present?
|
|
56
|
+
rows -= personal.reject { |row| owner && row["scope_key"] == owner }
|
|
57
|
+
end
|
|
58
|
+
rows.each_with_object({}) do |row, all|
|
|
59
|
+
row["questions"].each do |label, question|
|
|
60
|
+
lens_label = LensLabel.new(row["id"], label, question)
|
|
61
|
+
all[lens_label.key] = lens_label
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ACTIVE_TTL = 1.minute
|
|
67
|
+
|
|
68
|
+
# The labeling lenses of one tenant from the cache store, so keystroke
|
|
69
|
+
# search computes its vocabulary version without a query (R12). Any lens
|
|
70
|
+
# change bumps a per-model generation; the TTL bounds staleness for
|
|
71
|
+
# per-process cache stores.
|
|
72
|
+
def active_rows(model, tenant_key)
|
|
73
|
+
cache = Truffler.config.cache_store
|
|
74
|
+
generation = cache.read(generation_key(model.polymorphic_name)) || "0"
|
|
75
|
+
key = "truffler/lenses/#{model.polymorphic_name}/#{generation}/#{Canonical.digest(tenant_key.to_s)}"
|
|
76
|
+
cache.fetch(key, expires_in: ACTIVE_TTL) do
|
|
77
|
+
labeling_lenses(model, tenant_key: tenant_key).map do |lens|
|
|
78
|
+
{ "id" => lens.id, "scope_type" => lens.scope_type, "scope_key" => lens.scope_key, "questions" => lens.questions.to_h }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def changed!(record_type)
|
|
84
|
+
Truffler.config.cache_store.write(generation_key(record_type), SecureRandom.hex(8))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def generation_key(record_type)
|
|
88
|
+
"truffler/lenses/#{record_type}/generation"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def visible(model, tenant_key:, user_key: nil)
|
|
92
|
+
lenses = visible_lenses(model, tenant_key: tenant_key, user_key: user_key)
|
|
93
|
+
questions = lenses.each_with_object({}) { |lens, all| all.merge!(lens.storage_questions) }
|
|
94
|
+
fingerprints = questions.transform_values { |question| fingerprint(question) }
|
|
95
|
+
Visibility.new(questions: questions, fingerprints: fingerprints,
|
|
96
|
+
lens_fingerprints: (Canonical.digest(fingerprints) if fingerprints.any?), lens_ids: lenses.map(&:id))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# {"lens:<id>:<label>" => wire-shape question} for active visible lenses.
|
|
100
|
+
def visible_questions(model, tenant_key:, user_key: nil)
|
|
101
|
+
visible(model, tenant_key: tenant_key, user_key: user_key).questions
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A digest of every visible lens label's fingerprint, or nil when no lens
|
|
105
|
+
# is visible, so a vocabulary without lenses keeps its version.
|
|
106
|
+
def lens_fingerprints(model, tenant_key:, user_key: nil)
|
|
107
|
+
visible(model, tenant_key: tenant_key, user_key: user_key).lens_fingerprints
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The same formula as Vocabulary#fingerprint: canonical question plus the
|
|
111
|
+
# pinned Jev model.
|
|
112
|
+
def fingerprint(question, model: Truffler.config.model)
|
|
113
|
+
Canonical.digest(question: question, model: model)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Counts a search that used these lenses, which keeps them from expiring
|
|
117
|
+
# and tells developers which lenses to promote (R43).
|
|
118
|
+
def record_usage(lens_ids, now: Time.current)
|
|
119
|
+
return 0 if lens_ids.blank?
|
|
120
|
+
|
|
121
|
+
Lens.where(id: lens_ids).update_all([ "usage_count = usage_count + 1, last_used_at = ?, updated_at = ?", now, now ])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def digest(user_key)
|
|
125
|
+
OpenSSL::HMAC.hexdigest("SHA256", Truffler.config.secret_key_base, "truffler/lens/user/#{user_key}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Descriptions on encrypted models follow the miss-log rules (R29, R44):
|
|
129
|
+
# AR-encryption ciphertext when it is configured, nothing otherwise.
|
|
130
|
+
def seal(model, text)
|
|
131
|
+
Misses.seal(model, text)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def unseal(model, stored)
|
|
135
|
+
Misses.unseal(model, stored)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Misses
|
|
3
|
+
# Groups normalized queries by shared non-filler terms. The anchor term with
|
|
4
|
+
# the most distinct users claims every unclaimed query containing it; a
|
|
5
|
+
# cluster qualifies only when its queries came from enough distinct users,
|
|
6
|
+
# and it names only terms that each cleared the same gate, so no single
|
|
7
|
+
# user's wording leaves the log. Tokens with digits or "@" are dropped:
|
|
8
|
+
# they are exact identifiers (R18), not intents.
|
|
9
|
+
class Clusterer
|
|
10
|
+
MAX_TERMS = 3
|
|
11
|
+
FILLER = %w[
|
|
12
|
+
a about after all an and any are as at be before by can do find for from get give has have how i in is it
|
|
13
|
+
list me mine my of on or please search see show that the their them there these this those to was were
|
|
14
|
+
what when where which who why will with without you your
|
|
15
|
+
].to_set.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(model, min_distinct_users:)
|
|
18
|
+
@min = min_distinct_users
|
|
19
|
+
noun = model.model_name.human.downcase
|
|
20
|
+
@filler = FILLER | [ noun, noun.pluralize, *noun.split ]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# entries: [[normalized query, user digest or nil], ...]
|
|
24
|
+
def clusters(entries)
|
|
25
|
+
rows = entries.filter_map do |text, user|
|
|
26
|
+
terms = terms_for(text)
|
|
27
|
+
[ terms, user ] if terms.any?
|
|
28
|
+
end
|
|
29
|
+
claimed = Array.new(rows.size, false)
|
|
30
|
+
|
|
31
|
+
gated_terms(rows).filter_map do |anchor|
|
|
32
|
+
members = rows.each_index.select { |i| !claimed[i] && rows[i].first.include?(anchor) }
|
|
33
|
+
users = members.filter_map { |i| rows[i].last }.to_set
|
|
34
|
+
next if users.size < @min
|
|
35
|
+
|
|
36
|
+
members.each { |i| claimed[i] = true }
|
|
37
|
+
Cluster.new(terms: top_terms(rows.values_at(*members)), query_count: members.size, distinct_users: users.size)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def terms_for(text)
|
|
42
|
+
text.to_s.scan(/[[:alnum:]@._'-]+/).filter_map do |token|
|
|
43
|
+
next if token.match?(/[\d@]/)
|
|
44
|
+
|
|
45
|
+
term = token.delete("'").gsub(/\A[._-]+|[._-]+\z/, "").singularize
|
|
46
|
+
term if term.length > 1 && !@filler.include?(term) && !@filler.include?(token)
|
|
47
|
+
end.uniq
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def top_terms(rows)
|
|
53
|
+
gated_terms(rows).first(MAX_TERMS)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Terms seen from enough users, most users first, ties in reading order.
|
|
57
|
+
def gated_terms(rows)
|
|
58
|
+
users_by_term(rows).select { |_, users| users.size >= @min }.each_with_index
|
|
59
|
+
.sort_by { |(_, users), position| [ -users.size, position ] }.map { |(term, _), _| term }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def users_by_term(rows)
|
|
63
|
+
rows.each_with_object(Hash.new { |hash, term| hash[term] = Set.new }) do |(terms, user), index|
|
|
64
|
+
terms.each { |term| index[term] << user if user }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Misses
|
|
3
|
+
# Stores one miss per call. Recording is best-effort: a failure is
|
|
4
|
+
# instrumented by error class and never raises into the encoding path.
|
|
5
|
+
class Recorder
|
|
6
|
+
def call(model, tenant_key:, user_key:, query:)
|
|
7
|
+
return unless model.try(:truffler_definition)
|
|
8
|
+
|
|
9
|
+
normalized = Misses.normalize(query)
|
|
10
|
+
return if normalized.empty?
|
|
11
|
+
|
|
12
|
+
miss = Records::QueryMiss.create!(
|
|
13
|
+
record_type: model.polymorphic_name,
|
|
14
|
+
tenant_key: tenant_key&.to_s,
|
|
15
|
+
query_digest: Misses.digest(:query, normalized),
|
|
16
|
+
user_digest: (Misses.digest(:user, user_key) if user_key.present?),
|
|
17
|
+
query_text: Misses.seal(model, normalized)
|
|
18
|
+
)
|
|
19
|
+
Instrumentation.instrument("miss", record_type: miss.record_type, tenant_key: miss.tenant_key, outcome: "recorded")
|
|
20
|
+
miss
|
|
21
|
+
rescue Truffler::Error, ActiveRecord::ActiveRecordError, ActiveRecord::Encryption::Errors::Base => error
|
|
22
|
+
Instrumentation.instrument("miss", record_type: model.try(:name), outcome: "error", error_class: error.class.name)
|
|
23
|
+
Truffler.config.logger.warn("[truffler] query miss not recorded: #{error.class.name}")
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
alias_method :record, :call
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Misses
|
|
3
|
+
# Candidate label questions for developers (F4). Adding one stays a manual
|
|
4
|
+
# step followed by backfill. Encrypted models without AR encryption have no
|
|
5
|
+
# stored text, so their qualifying misses surface as digest-only counts.
|
|
6
|
+
module Suggestions
|
|
7
|
+
Suggestion = Data.define(:terms, :query_count, :distinct_users, :label_key, :question)
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def for(record_type, tenant_key: ALL_TENANTS, min_distinct_users: nil)
|
|
12
|
+
model = Misses.resolve(record_type)
|
|
13
|
+
entries = Misses.entries(model, tenant_key: tenant_key)
|
|
14
|
+
gate = Misses.gate(min_distinct_users)
|
|
15
|
+
texts = entries.filter_map { |text, user, _| [ text, user ] if text }
|
|
16
|
+
|
|
17
|
+
suggestions = Clusterer.new(model, min_distinct_users: gate).clusters(texts).map { |cluster| draft(model, cluster) }
|
|
18
|
+
suggestions + digest_only(entries.reject(&:first), gate)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def report(record_type, io: $stdout, tenant_key: ALL_TENANTS)
|
|
22
|
+
model = Misses.resolve(record_type)
|
|
23
|
+
suggestions = self.for(model, tenant_key: tenant_key)
|
|
24
|
+
return io.puts("No query miss clusters for #{model.name} from at least #{Misses.gate(nil)} distinct users.") if suggestions.empty?
|
|
25
|
+
|
|
26
|
+
suggestions.each do |suggestion|
|
|
27
|
+
terms = suggestion.terms.any? ? suggestion.terms.join(", ") : "(encrypted, no stored text)"
|
|
28
|
+
io.puts "#{suggestion.query_count} queries from #{suggestion.distinct_users} users: #{terms}"
|
|
29
|
+
io.puts %( label :#{suggestion.label_key}, :noul, question: "#{suggestion.question}") if suggestion.question
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def draft(model, cluster)
|
|
34
|
+
Suggestion.new(
|
|
35
|
+
terms: cluster.terms, query_count: cluster.query_count, distinct_users: cluster.distinct_users,
|
|
36
|
+
label_key: cluster.terms.join("_").parameterize(separator: "_"),
|
|
37
|
+
question: "Is this #{model.model_name.human.downcase} about #{cluster.terms.to_sentence}?"
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def digest_only(entries, gate)
|
|
42
|
+
entries.group_by(&:last).filter_map do |_, misses|
|
|
43
|
+
users = misses.filter_map { |_, user, _| user }.uniq.size
|
|
44
|
+
Suggestion.new(terms: [], query_count: misses.size, distinct_users: users, label_key: nil, question: nil) if users >= gate
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
# The query miss log (R28, R29). The query encoder calls `hook` when an
|
|
3
|
+
# encoding matched no label; developers and the lens proposer read only
|
|
4
|
+
# aggregated clusters that passed the distinct-user gate.
|
|
5
|
+
module Misses
|
|
6
|
+
ALL_TENANTS = :all
|
|
7
|
+
MAX_ROWS = 10_000
|
|
8
|
+
|
|
9
|
+
Cluster = Data.define(:terms, :query_count, :distinct_users)
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# The callable the encoder invokes: `call(model, tenant_key:, user_key:, query:)`.
|
|
14
|
+
def hook
|
|
15
|
+
@hook ||= Recorder.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record(model, tenant_key:, user_key:, query:)
|
|
19
|
+
hook.call(model, tenant_key: tenant_key, user_key: user_key, query: query)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Clusters of normalized non-filler terms with their query and distinct-user
|
|
23
|
+
# counts, newest misses within the retention window only. Every exposed term
|
|
24
|
+
# was itself seen from at least the gate's number of users. The gate can be
|
|
25
|
+
# raised per call but never lowered below `miss_min_distinct_users`.
|
|
26
|
+
def clusters(record_type, tenant_key:, min_distinct_users: nil)
|
|
27
|
+
model = resolve(record_type)
|
|
28
|
+
texts = entries(model, tenant_key: tenant_key).filter_map { |text, user, _| [ text, user ] if text }
|
|
29
|
+
Clusterer.new(model, min_distinct_users: gate(min_distinct_users)).clusters(texts)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# [normalized query or nil, user digest, query digest] per retained miss.
|
|
33
|
+
def entries(model, tenant_key:)
|
|
34
|
+
scope = Records::QueryMiss.for_model(model).retained
|
|
35
|
+
scope = scope.where(tenant_key: tenant_key) unless tenant_key == ALL_TENANTS
|
|
36
|
+
scope.order(created_at: :desc).limit(MAX_ROWS).map { |miss| [ miss.query, miss.user_digest, miss.query_digest ] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def gate(min_distinct_users)
|
|
40
|
+
[ min_distinct_users.to_i, Truffler.config.miss_min_distinct_users.to_i, 1 ].max
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def normalize(query)
|
|
44
|
+
Search::Query.normalize(query)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def digest(purpose, value)
|
|
48
|
+
OpenSSL::HMAC.hexdigest("SHA256", Truffler.config.secret_key_base, "truffler/miss/#{purpose}/#{value}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def encrypted_model?(model)
|
|
52
|
+
Array(model.try(:encrypted_attributes)).any?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def encryption_configured?
|
|
56
|
+
config = ActiveRecord::Encryption.config
|
|
57
|
+
config.has_primary_key? && config.has_key_derivation_salt?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Text stored about an encrypted model is AR-encryption ciphertext when
|
|
61
|
+
# that is configured, and nothing otherwise (R29).
|
|
62
|
+
def seal(model, text)
|
|
63
|
+
return text if text.nil? || !encrypted_model?(model)
|
|
64
|
+
|
|
65
|
+
ActiveRecord::Encryption.encryptor.encrypt(text) if encryption_configured?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def unseal(model, stored)
|
|
69
|
+
return stored if stored.nil? || !encrypted_model?(model)
|
|
70
|
+
|
|
71
|
+
ActiveRecord::Encryption.encryptor.decrypt(stored)
|
|
72
|
+
rescue ActiveRecord::Encryption::Errors::Base
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve(record_type)
|
|
77
|
+
model = record_type.is_a?(Class) ? record_type : record_type.to_s.safe_constantize
|
|
78
|
+
raise ArgumentError, "#{record_type} is not a truffler model" unless model.try(:truffler_definition)
|
|
79
|
+
|
|
80
|
+
model
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Model
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
class_attribute :truffler_definition, instance_accessor: false, instance_predicate: false
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class_methods do
|
|
10
|
+
# With a block, declares what truffler labels and searches. With a
|
|
11
|
+
# query, runs a keystroke search:
|
|
12
|
+
#
|
|
13
|
+
# Email.truffler("needs action", tenant: account.id, scope: Email.all, user: current_user)
|
|
14
|
+
def truffler(query = nil, **options, &block)
|
|
15
|
+
return truffler_search(query, **options) unless block || (query.nil? && options.empty?)
|
|
16
|
+
raise ArgumentError, "truffler needs a declaration block or a query" unless block
|
|
17
|
+
raise ArgumentError, "truffler takes a declaration block or a query, not both" if query || options.any?
|
|
18
|
+
|
|
19
|
+
definition = Definition.new(self)
|
|
20
|
+
Definition::DSL.new(definition).instance_exec(&block)
|
|
21
|
+
definition.validate!
|
|
22
|
+
install_truffler_callbacks unless truffler_definition
|
|
23
|
+
self.truffler_definition = definition
|
|
24
|
+
Truffler.registry.register(self)
|
|
25
|
+
definition
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def truffler_search(query, tenant: nil, scope: nil, user: nil, suppressed: [], surface: nil, **options)
|
|
29
|
+
Search::Keystroke.new(self, query, tenant: tenant, scope: scope, user: user, suppressed: suppressed, surface: surface,
|
|
30
|
+
**options).call
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Records matching the same search that arrived after `since`, usually
|
|
34
|
+
# a result's watermark (R25).
|
|
35
|
+
def jev_new_matches_count(query, tenant: nil, scope: nil, user: nil, since:, suppressed: [])
|
|
36
|
+
Search::Keystroke.new(self, query, tenant: tenant, scope: scope, user: user, suppressed: suppressed).count(since: since)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The explicit action (R22): starts a Smart run and returns it,
|
|
40
|
+
# already reserved. The keystroke list stays as it was.
|
|
41
|
+
def jev_smart_search(query, tenant: nil, scope: nil, user: nil, surface: nil, suppressed: [])
|
|
42
|
+
SmartSearch.start(self, query, tenant: tenant, scope: scope, user: user, surface: surface, suppressed: suppressed)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Cancels the searcher's in-flight Smart run, as on a query edit or a
|
|
46
|
+
# chip change (R24).
|
|
47
|
+
def jev_cancel_smart_search(tenant: nil, user: nil, surface: nil)
|
|
48
|
+
SmartSearch.cancel(self, tenant: tenant, user: user, surface: surface)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def install_truffler_callbacks
|
|
54
|
+
after_commit :truffler_enqueue_labeling, on: %i[create update]
|
|
55
|
+
after_commit :truffler_forget, on: :destroy
|
|
56
|
+
after_commit :truffler_enqueue_embedding, on: %i[create update]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Rewrites this record's host-supplied labels (`label ..., from:`) now,
|
|
61
|
+
# without Jev. Call it when the host's own classifier updates answers
|
|
62
|
+
# outside the watched columns, e.g. from the job that classified it.
|
|
63
|
+
def truffler_refresh_labels!
|
|
64
|
+
definition = self.class.truffler_definition
|
|
65
|
+
keys = definition.supplied_labels.map(&:key)
|
|
66
|
+
Labeling::Supplied.new(self.class).write([ [ self, keys ] ], tenant_key: definition.tenant_key_for(self)) if keys.any?
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def truffler_enqueue_labeling
|
|
73
|
+
definition = self.class.truffler_definition
|
|
74
|
+
if previously_new_record?
|
|
75
|
+
Labeling::Queue.new(self.class).enqueue(self)
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
changed = saved_changes.keys
|
|
80
|
+
if changed.intersect?([ *definition.fields, definition.tenant_column ].compact)
|
|
81
|
+
truffler_expire_labels
|
|
82
|
+
else
|
|
83
|
+
watched = definition.supplied_labels.select { |label| changed.intersect?(label.watch) }
|
|
84
|
+
return if watched.empty?
|
|
85
|
+
|
|
86
|
+
truffler_expire_labels(watched.map(&:key))
|
|
87
|
+
end
|
|
88
|
+
Labeling::Queue.new(self.class).enqueue(self)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Stored values keep serving search until the relabel lands; clearing the
|
|
92
|
+
# fingerprints is what makes the labeler write or ask them again.
|
|
93
|
+
def truffler_expire_labels(keys = nil)
|
|
94
|
+
rows = Records::Label.where(record_type: self.class.polymorphic_name, record_id: id)
|
|
95
|
+
rows = rows.merge(keys.map { |key| Records::Label.for_label(key) }.reduce(:or)) if keys
|
|
96
|
+
rows.update_all(fingerprint: "")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def truffler_forget
|
|
100
|
+
Labeling::Queue.new(self.class).forget(self)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def truffler_enqueue_embedding
|
|
104
|
+
definition = self.class.truffler_definition
|
|
105
|
+
return unless Embeddings.managed?(definition)
|
|
106
|
+
return unless previously_new_record? || saved_changes.keys.intersect?([ *definition.fields, definition.tenant_column ].compact)
|
|
107
|
+
|
|
108
|
+
Jobs::EmbedJob.perform_later(self.class.polymorphic_name, id)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Truffler
|
|
2
|
+
module Providers
|
|
3
|
+
# Decides whether the provider backup runs for one explicit action and,
|
|
4
|
+
# when it does, marks the section pending and enqueues the search. Local
|
|
5
|
+
# sections are never touched.
|
|
6
|
+
class Backup
|
|
7
|
+
def initialize(run, query:, tenant_key:, user_key:, local_result: nil)
|
|
8
|
+
@run = run
|
|
9
|
+
@query = Search::Query.wrap(query)
|
|
10
|
+
@tenant_key = tenant_key&.to_s
|
|
11
|
+
@user_key = user_key&.to_s
|
|
12
|
+
@local_result = local_result
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def start
|
|
16
|
+
provider = Providers.declared(definition)
|
|
17
|
+
return unless provider
|
|
18
|
+
|
|
19
|
+
reason = self.reason
|
|
20
|
+
instrument(reason)
|
|
21
|
+
return unless reason
|
|
22
|
+
|
|
23
|
+
@run.update_section(SECTION, Providers.state(provider, :pending))
|
|
24
|
+
Jobs::ProviderSearchJob.perform_later(@run.id.to_s, @tenant_key, @user_key)
|
|
25
|
+
reason
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reason
|
|
29
|
+
return if @query.blank?
|
|
30
|
+
return :exact_text if @query.exact_text?
|
|
31
|
+
|
|
32
|
+
:weak_local if weak_local?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def definition
|
|
38
|
+
@run.model.truffler_definition
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def weak_local?
|
|
42
|
+
return @local_result.invite_row.present? if @local_result
|
|
43
|
+
return @run.local_weak? if @run.respond_to?(:local_weak?)
|
|
44
|
+
unless @run.respond_to?(:candidate_ids)
|
|
45
|
+
raise ArgumentError, "Providers.start needs local_result: or a run that responds to local_weak? or candidate_ids"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Array(@run.candidate_ids).size < definition.weak_below
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def instrument(reason)
|
|
52
|
+
Instrumentation.instrument("provider_start", run_id: @run.id.to_s, record_type: @run.model.polymorphic_name,
|
|
53
|
+
tenant_key: @tenant_key, user_key: @user_key, section: SECTION.to_s, outcome: reason ? :enqueued : :skipped,
|
|
54
|
+
reason: reason)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|