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.
Files changed (214) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +18 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +425 -0
  5. data/bench/cassettes/004cad52c71fc8d847bf388eb9c2c288e1169b65ec4a28ce040cb7fea7c55f04.json +1 -0
  6. data/bench/cassettes/0547d3d23c3fbaae86772ffa0e4e96475aa152a2556b8fa4bc7568effcb81a28.json +1 -0
  7. data/bench/cassettes/07aa23d166ab3500178e3f3c0838183051b093507bef88e1e2d29efdf5759ee6.json +1 -0
  8. data/bench/cassettes/0858c5006c0bfac3e970ddaff3c139f6c110dbe3d8539fd95263706c637cdc8e.json +1 -0
  9. data/bench/cassettes/0c0eb7c27d76bd0b9263690a63cd92d6b68c305826318555fd5b084a09970b78.json +1 -0
  10. data/bench/cassettes/0fada0c0a103882e2895f7f455d73d2cbe2b4a81bf743bb19b2b4f6e0130126b.json +1 -0
  11. data/bench/cassettes/1117d79ac7c43b21beaf75e510461f91cd4ecc3cdbbe461f1794644af4aaf8f3.json +1 -0
  12. data/bench/cassettes/11b7444a074cca4d0ef1535db0cf0edefe4c34eac4be3c593c9c2f33d3b038b8.json +1 -0
  13. data/bench/cassettes/11d00371d1b993675be00463159164536f1f8ed90a48652b0935eb9c36f8b684.json +1 -0
  14. data/bench/cassettes/1456ebe12616ce961d68a14eada62778a7271ebd3d1b4cb6eefd6857651c7afc.json +1 -0
  15. data/bench/cassettes/14c3693f64b0e9ea7e470277fccae6ab501d404185eec5bb9074ef4b75c572c0.json +1 -0
  16. data/bench/cassettes/14fe9f6d8055d41f5d970dc32ab640c213ea8782d365c82d32b9884ba4f28c67.json +1 -0
  17. data/bench/cassettes/1a1d8557e4698426c2467aa573a240357715d3d9e3fa77bf066672a8b6bf9a86.json +1 -0
  18. data/bench/cassettes/1b5a8aaaf98305560588246cd51f53c6c185a951f8018c26bf588954d9de1cea.json +1 -0
  19. data/bench/cassettes/1fe596be747e33e9c7e4d7c50de32613957ac66bee43679b4bed96c551b1fd0a.json +1 -0
  20. data/bench/cassettes/21ebce6db6d39b1747dccf25bd3ceb298e1285425190b4d78830c395a7ad786a.json +1 -0
  21. data/bench/cassettes/23066253621e9cb00fd93cbe95ef1124ff0715585fcb6325b3abbd0802b2c303.json +1 -0
  22. data/bench/cassettes/2f6576325dfab696cec8d8b35fa89c29960bee15d10dab31c91cef507f4842be.json +1 -0
  23. data/bench/cassettes/33ec27d22c0f63dbbe93008f50af07fa8667a5c806a9fbfc7897683915412b40.json +1 -0
  24. data/bench/cassettes/351f745b0c47e0cf3272b0fea7fc4941b6aa733f013c55a9a19707b542543200.json +1 -0
  25. data/bench/cassettes/37d974fdc0c1876413111c244d363c8d2306e3229fec668c158b5c924ffdb983.json +1 -0
  26. data/bench/cassettes/39524467463dd612cbf03e314b9cfd8cbe1c5f06969da2289359cf7cf83de5da.json +1 -0
  27. data/bench/cassettes/3f2c758c111ab7f1163ba579f2329718afe15620507d356d3ebe2a053df237ea.json +1 -0
  28. data/bench/cassettes/44bbc3a3f0ceb0edb443b080b939af80dd145795505bd13cac113a35365d0f04.json +1 -0
  29. data/bench/cassettes/45bf701a13c348acb45a3b3a56990570e3caf797c3daee5d1358a477593ce587.json +1 -0
  30. data/bench/cassettes/4676a0f2df29493fdfa0b7cd75c097e647373862d88373dc0e1fa9fca26707c2.json +1 -0
  31. data/bench/cassettes/49708a50e3c78f45beed2f1b8d8d5375b99ea4ab3497fed7650e6ece4ec81ba8.json +1 -0
  32. data/bench/cassettes/54ea30ecca8fa49f607d5c8265296eb16edb48d0620a7399c6480d4cb0f43ae1.json +1 -0
  33. data/bench/cassettes/550e73a8291f53d0f2b008f166560eff4d10cc7c09a927d3c4f70544f2578300.json +1 -0
  34. data/bench/cassettes/570039823d5af1b6c1d4bcbe14f9802d40e851f8e4946278a46884c76c0ceee1.json +1 -0
  35. data/bench/cassettes/5ae9dce82fa7b9084b592f657a24acfd5852c4fcb091a6a8e22f2cbbf947bcdd.json +1 -0
  36. data/bench/cassettes/5c40794d3bf6e229c6ffd26922388776121f971ef27279e02a35ce004e0794dd.json +1 -0
  37. data/bench/cassettes/62f2e71a2d9cfdc18fdd6600f5a1450519b25a75c2f799b7695258627250497d.json +1 -0
  38. data/bench/cassettes/642becd7ad9cd4cc2ac6d389ef8ff14fd9fe125beca834afee625ea2c31476be.json +1 -0
  39. data/bench/cassettes/656cc1158890e928c129d9cd54c1e08af1b6a2c0f6f94bacf395b13a198dc81f.json +1 -0
  40. data/bench/cassettes/67b30efd5681046fb5f2252d73f9aedf603be1b7c7895e4e71c6cfb88238309e.json +1 -0
  41. data/bench/cassettes/691958dd39537d72c4b0ab4ba69fea3c51b8e06a49a772f3ffd5bb5747dfe99d.json +1 -0
  42. data/bench/cassettes/6931e51f0455b5e3260165aa22a445d3a4f782ae5233a961384149cfbb045fe0.json +1 -0
  43. data/bench/cassettes/6b79cf1a80fc9edd09bb4606613ff2de2ab2bc61487983dffac1174025270997.json +1 -0
  44. data/bench/cassettes/6d683884d5d039db072ac18c9420bdcc989838f08a71096076cccc2edcd48234.json +1 -0
  45. data/bench/cassettes/6df49dc7eae4104adf1b020fd846d893da3e49f4f13a4c6fd1f87a78c410ba31.json +1 -0
  46. data/bench/cassettes/75cc5d99bbc1d48ace6095d41daae1e2e2c829291f01d40f0ce40d6eea52c0bf.json +1 -0
  47. data/bench/cassettes/771a86cbb6c025101384a50d01a4745683e1c2b17bbfff73e40e475dbf016efb.json +1 -0
  48. data/bench/cassettes/7a2a93c357473f4a289a6ec250e3bfe6f51152a0c448e5420ffebee7872f9d11.json +1 -0
  49. data/bench/cassettes/7a4774a02c3eebe24d4fbd443ea90661711fe5cb8248bce7a2dedfedf6ddbc03.json +1 -0
  50. data/bench/cassettes/7c1fa04f8eb43251ca3f6652977b698e4e6ba5e120da8301b4dbce79ef01ccb9.json +1 -0
  51. data/bench/cassettes/7c4dea48c2bc2c413da81307ce3cbb2749b20e16e4d831fca5861decf11ec55b.json +1 -0
  52. data/bench/cassettes/7dd499da389eaeeb59eba533695c20fd1a43e25f89f21e3456b826f9953b2010.json +1 -0
  53. data/bench/cassettes/80438d0bbfe106baa236eef358e46260693e6491f67e7a9f8ea1076c6fbfd449.json +1 -0
  54. data/bench/cassettes/80db562534181514f243ac74d7ddaec1fafc28b2b72ae35482cfc2de8f2d9e61.json +1 -0
  55. data/bench/cassettes/825a51543665a468a55c823788c68eb5785c90be8131988f72bf98f4f0294177.json +1 -0
  56. data/bench/cassettes/83d8276c227b73e542bd70c023d9a8ffa1c2cf4dd78e71d61457f097bc743d19.json +1 -0
  57. data/bench/cassettes/891405ffd7727b6d4c4186e2c60a08aa8b62c7b711bd1558c8033fcf8e4178a4.json +1 -0
  58. data/bench/cassettes/89fabff05dc4c0abdf630781a324b7802cbc0dc8bd09b20ce59643465a2aeaec.json +1 -0
  59. data/bench/cassettes/8cbceece3ca6136b6c6bb71c9bf28f409a5fa070596ebfc0f3187aba287bf6d3.json +1 -0
  60. data/bench/cassettes/8d56da27350d9dea20468eae30fa51cbbb10637c06d977b3e1a00734966be46f.json +1 -0
  61. data/bench/cassettes/8fb43b452b5e15248f34bf48e41cfc227129cfe349fe4b3fc4ab7133d66914bc.json +1 -0
  62. data/bench/cassettes/92e5426bd0c1f31d7acca92f77d7f2586e74054467729f0be28d139f515354d5.json +1 -0
  63. data/bench/cassettes/938be225155e1952b817652778b7cab377017fc4a219628ae89ad01a9c1151bc.json +1 -0
  64. data/bench/cassettes/9519ad701b28e7b2c5f1e1cad5df1ec3e5254c8cec969b9bee1361af8803d39d.json +1 -0
  65. data/bench/cassettes/9536e8ed68330eda400d765c3ca16a4dd3bfe0d026b569599a33e7e4951f4860.json +1 -0
  66. data/bench/cassettes/99c6e5aefd97722f4028bacc11d299eb9036805cd5cf7bf75f879ebe3f27c00a.json +1 -0
  67. data/bench/cassettes/9cdfd2f3ef8dc4cde917a9d196e3137159067c10ec8d2132f32da034865f9959.json +1 -0
  68. data/bench/cassettes/a1e3030984d4e1982fb5a2b2068664187358c88c6b71f768841d3e78406e9535.json +1 -0
  69. data/bench/cassettes/a2140f51b8fa25da73efd387a41f9eb84ffbc1f1ab47dec0c3b37b249ff373a0.json +1 -0
  70. data/bench/cassettes/a36d248ce5c26fd66430b780c8ecdd176f29830ac4edb2bf0b2e07cb93ffb865.json +1 -0
  71. data/bench/cassettes/a64329816ca1e537e90b89862a86be119e1e6236c6f3cf0141a96505b0734364.json +1 -0
  72. data/bench/cassettes/aba24c67dcaefa51c204a529b5ea3fe01333181a608a8ab8899568c731677e7d.json +1 -0
  73. data/bench/cassettes/aed233524926f2b4ff8c2ec21224bd284595c91eb57e0fb6981cdc8ac7e7d8d3.json +1 -0
  74. data/bench/cassettes/b00e9da6403e48aa1f4f9346419f88b2a336e1a2ac4a28515d84b2337eb777b2.json +1 -0
  75. data/bench/cassettes/b02f4c76c073882ff0fd12fddc28e4db52d9e6486a130194bf1057c7693de831.json +1 -0
  76. data/bench/cassettes/b6a9cb89ae4afd08a6919eaf7f609790c895d4cf22d47dad6c3b287b9278be87.json +1 -0
  77. data/bench/cassettes/b71b982225eab48d02c81913877571dfa53717c0983d1d72cbe3787242dda055.json +1 -0
  78. data/bench/cassettes/bda5e115b566e7f955aa45222859fe4f725949d5f91e3d794821701491448510.json +1 -0
  79. data/bench/cassettes/c0ac703268faf8d49dbc1ad0e877693a9f52181ae6070de844fcd4218d33d5e4.json +1 -0
  80. data/bench/cassettes/c0b57bcbb4004edd44c2d8c7e1f8cfc3567d954a3d5b21b2cda7257e3bc07437.json +1 -0
  81. data/bench/cassettes/d1ade9adde20981211c470a77b4d560264cae290941e692be6876a79227e3d18.json +1 -0
  82. data/bench/cassettes/d28de3038544a5bf9c8425b6976e360fae71c5ca96409784ad6642703701986a.json +1 -0
  83. data/bench/cassettes/d6418acb9f7f58ffdf3049481cbd17cfb3f0581064e157c9c58c19a3fc26b4d4.json +1 -0
  84. data/bench/cassettes/d71e8cf7c2be4b6e4089eb40c5a108c0c45fc58398af5138f905129f640afbb8.json +1 -0
  85. data/bench/cassettes/d9672c45b5358b92f7740abfb20b287235cf949990c88558f41d3a9c00eb6d3b.json +1 -0
  86. data/bench/cassettes/da0c9ce695111f6acf3cfb58dc0494f79230739d1773377db420bf438fd2e244.json +1 -0
  87. data/bench/cassettes/db57e3a15971cd2832313172c0d7df6e6a48b13a276ba9877eeb7d39389d74d3.json +1 -0
  88. data/bench/cassettes/db9c8c4d0a8d855a2578572afe5edc5eebb39c0fd5f04489614ea2557ec4f0cd.json +1 -0
  89. data/bench/cassettes/e2c396b0475a07a615dde9208b088e9e479720c1ec5889ade07fbfd0d28fb517.json +1 -0
  90. data/bench/cassettes/e3006ccc97646f43f4ea23694665526e47982fa1ea413359b9669071f6cb990d.json +1 -0
  91. data/bench/cassettes/e623737636e797d22f80d128cbff46101837044e159f41fa62a8d3d0cc6bb996.json +1 -0
  92. data/bench/cassettes/ea0cefaaac1a62e8eac962391df025a3b14c3a3d5b69fe57d60b65811010908c.json +1 -0
  93. data/bench/cassettes/ee3e06456cf4201ab46f579dd53bdea4071ce43bfe02e9b870bcb4b9c1c680b4.json +1 -0
  94. data/bench/cassettes/f26e675da9088f08cb76dd6b3fd89230b84fd1b9be3767c36857217bda2da15b.json +1 -0
  95. data/bench/cassettes/f396f34b2356d42af385eac9f5760e97e553a584c3f93da7bd618ad311e04d93.json +1 -0
  96. data/bench/cassettes/fce02c5c04cb8c27e868f6c54cfcbbbf69bde2f7f612d50ff8fb6d0aec9df3e3.json +1 -0
  97. data/bench/cassettes/ffc09a04b083822c1597dd96bb27ee4cde365bc8346175ebf6e378ab2ab1b598.json +1 -0
  98. data/bench/cassettes/manifest.json +1 -0
  99. data/bench/fixtures/gold.jsonl +27 -0
  100. data/bench/fixtures/injection.jsonl +6 -0
  101. data/bench/fixtures/records.jsonl +90 -0
  102. data/bench/params.yml +30 -0
  103. data/lib/generators/truffler/install/install_generator.rb +51 -0
  104. data/lib/generators/truffler/install/templates/channel.rb.tt +17 -0
  105. data/lib/generators/truffler/install/templates/initializer.rb.tt +23 -0
  106. data/lib/generators/truffler/install/templates/migration.rb.tt +109 -0
  107. data/lib/tasks/truffler/bench.rake +30 -0
  108. data/lib/tasks/truffler/suggestions.rake +8 -0
  109. data/lib/tasks/truffler.rake +26 -0
  110. data/lib/truffler/answers.rb +66 -0
  111. data/lib/truffler/benchmark/adapters.rb +80 -0
  112. data/lib/truffler/benchmark/database.rb +31 -0
  113. data/lib/truffler/benchmark/dataset.rb +65 -0
  114. data/lib/truffler/benchmark/email.rb +50 -0
  115. data/lib/truffler/benchmark/generator.rb +188 -0
  116. data/lib/truffler/benchmark/metrics.rb +62 -0
  117. data/lib/truffler/benchmark/params.rb +91 -0
  118. data/lib/truffler/benchmark/runner.rb +370 -0
  119. data/lib/truffler/benchmark/synthetic_jev.rb +85 -0
  120. data/lib/truffler/benchmark.rb +29 -0
  121. data/lib/truffler/broadcaster.rb +46 -0
  122. data/lib/truffler/budget.rb +118 -0
  123. data/lib/truffler/canonical.rb +27 -0
  124. data/lib/truffler/clients/base.rb +49 -0
  125. data/lib/truffler/clients/callable.rb +26 -0
  126. data/lib/truffler/clients/cassette.rb +44 -0
  127. data/lib/truffler/clients/fake.rb +85 -0
  128. data/lib/truffler/clients/ruby_llm_typesafe.rb +32 -0
  129. data/lib/truffler/configuration.rb +89 -0
  130. data/lib/truffler/definition.rb +176 -0
  131. data/lib/truffler/embeddings/backfill.rb +48 -0
  132. data/lib/truffler/embeddings/column_store.rb +65 -0
  133. data/lib/truffler/embeddings/embedder.rb +53 -0
  134. data/lib/truffler/embeddings/fake_embedder.rb +45 -0
  135. data/lib/truffler/embeddings/label_vector.rb +81 -0
  136. data/lib/truffler/embeddings/neighbor_store.rb +66 -0
  137. data/lib/truffler/embeddings/ruby_llm_embedder.rb +25 -0
  138. data/lib/truffler/embeddings/ruby_store.rb +12 -0
  139. data/lib/truffler/embeddings/vector_store.rb +84 -0
  140. data/lib/truffler/embeddings.rb +30 -0
  141. data/lib/truffler/errors.rb +40 -0
  142. data/lib/truffler/instrumentation.rb +17 -0
  143. data/lib/truffler/jobs/backfill_job.rb +45 -0
  144. data/lib/truffler/jobs/embed_job.rb +33 -0
  145. data/lib/truffler/jobs/encode_query_job.rb +17 -0
  146. data/lib/truffler/jobs/expire_lenses_job.rb +15 -0
  147. data/lib/truffler/jobs/label_flush_job.rb +39 -0
  148. data/lib/truffler/jobs/lens_backfill_job.rb +33 -0
  149. data/lib/truffler/jobs/provider_search_job.rb +18 -0
  150. data/lib/truffler/jobs/prune_query_misses_job.rb +15 -0
  151. data/lib/truffler/jobs/rerank_chunk_job.rb +14 -0
  152. data/lib/truffler/jobs/resume_job.rb +61 -0
  153. data/lib/truffler/jobs/smart_search_job.rb +14 -0
  154. data/lib/truffler/label_definition.rb +153 -0
  155. data/lib/truffler/labeling/backfill.rb +189 -0
  156. data/lib/truffler/labeling/labeler.rb +161 -0
  157. data/lib/truffler/labeling/queue.rb +125 -0
  158. data/lib/truffler/labeling/request_builder.rb +87 -0
  159. data/lib/truffler/labeling/supplied.rb +70 -0
  160. data/lib/truffler/lenses/activator.rb +51 -0
  161. data/lib/truffler/lenses/backfill.rb +112 -0
  162. data/lib/truffler/lenses/draft.rb +16 -0
  163. data/lib/truffler/lenses/drafter.rb +147 -0
  164. data/lib/truffler/lenses/fake_generator.rb +58 -0
  165. data/lib/truffler/lenses/lens.rb +161 -0
  166. data/lib/truffler/lenses/lens_label.rb +51 -0
  167. data/lib/truffler/lenses/policy.rb +46 -0
  168. data/lib/truffler/lenses/previewer.rb +217 -0
  169. data/lib/truffler/lenses/proposer.rb +53 -0
  170. data/lib/truffler/lenses/ruby_llm_generator.rb +42 -0
  171. data/lib/truffler/lenses/scope.rb +35 -0
  172. data/lib/truffler/lenses/sealed_description.rb +29 -0
  173. data/lib/truffler/lenses/settings.rb +45 -0
  174. data/lib/truffler/lenses/validation.rb +69 -0
  175. data/lib/truffler/lenses/version.rb +34 -0
  176. data/lib/truffler/lenses.rb +138 -0
  177. data/lib/truffler/misses/clusterer.rb +69 -0
  178. data/lib/truffler/misses/recorder.rb +29 -0
  179. data/lib/truffler/misses/suggestions.rb +49 -0
  180. data/lib/truffler/misses.rb +83 -0
  181. data/lib/truffler/model.rb +111 -0
  182. data/lib/truffler/providers/backup.rb +58 -0
  183. data/lib/truffler/providers/runner.rb +67 -0
  184. data/lib/truffler/providers.rb +57 -0
  185. data/lib/truffler/query_encoding/cache.rb +71 -0
  186. data/lib/truffler/query_encoding/encoder.rb +183 -0
  187. data/lib/truffler/query_encoding/prefetch.rb +34 -0
  188. data/lib/truffler/query_encoding.rb +12 -0
  189. data/lib/truffler/questions.rb +78 -0
  190. data/lib/truffler/railtie.rb +11 -0
  191. data/lib/truffler/records/embedding.rb +39 -0
  192. data/lib/truffler/records/label.rb +11 -0
  193. data/lib/truffler/records/query_miss.rb +20 -0
  194. data/lib/truffler/records/record_state.rb +30 -0
  195. data/lib/truffler/redaction.rb +24 -0
  196. data/lib/truffler/registry.rb +16 -0
  197. data/lib/truffler/search/encoding.rb +84 -0
  198. data/lib/truffler/search/encoding_cache.rb +68 -0
  199. data/lib/truffler/search/keystroke.rb +149 -0
  200. data/lib/truffler/search/query.rb +67 -0
  201. data/lib/truffler/search/result.rb +100 -0
  202. data/lib/truffler/search/sql.rb +197 -0
  203. data/lib/truffler/smart_search/dispatcher.rb +88 -0
  204. data/lib/truffler/smart_search/reranker.rb +105 -0
  205. data/lib/truffler/smart_search/run.rb +306 -0
  206. data/lib/truffler/smart_search/starter.rb +73 -0
  207. data/lib/truffler/smart_search/store.rb +89 -0
  208. data/lib/truffler/smart_search.rb +43 -0
  209. data/lib/truffler/tokens.rb +12 -0
  210. data/lib/truffler/usage.rb +7 -0
  211. data/lib/truffler/version.rb +3 -0
  212. data/lib/truffler/vocabulary.rb +48 -0
  213. data/lib/truffler.rb +41 -0
  214. 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