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,84 @@
1
+ module Truffler
2
+ module Search
3
+ # What query encoding (U9) decided about one query, as keystroke search
4
+ # reads it from the cache. Keys are label storage keys (`needs_action`,
5
+ # `category:billing`, `lens:<id>:<label>`).
6
+ #
7
+ # - `filters`: hard filters, key => minimum value, applied as EXISTS.
8
+ # - `boosts`: key => weight, shown as boost chips.
9
+ # - `intent_vector`: the sparse query vector of KTD20, key => weight, used
10
+ # for the label term SUM(weight * value). Defaults to `boosts`; the
11
+ # encoder decides whether filtered labels also carry weight here.
12
+ # - `keyword_tokens`: query tokens for the keyword source; nil means
13
+ # every token that is not a label term.
14
+ # - `label_term_tokens`: tokens that named a label rather than a keyword.
15
+ Encoding = Data.define(:filters, :boosts, :intent_vector, :keyword_tokens, :label_term_tokens) do
16
+ def self.load(value, query)
17
+ return value if value.is_a?(self)
18
+ return if value.nil?
19
+
20
+ tokens = query.tokens
21
+ new(filters: value["filters"], boosts: value["boosts"], intent_vector: value["intent_vector"],
22
+ keyword_tokens: value["keyword_positions"]&.map { |position| tokens[position] }&.compact,
23
+ label_term_tokens: Array(value["label_term_positions"]).filter_map { |position| tokens[position] })
24
+ end
25
+
26
+ def initialize(filters: {}, boosts: {}, intent_vector: nil, keyword_tokens: nil, label_term_tokens: [])
27
+ filters = weights(filters)
28
+ boosts = weights(boosts)
29
+ intent_vector = weights(intent_vector || boosts).reject { |_, weight| weight.zero? }
30
+ super(filters: filters.freeze, boosts: boosts.freeze, intent_vector: intent_vector.freeze,
31
+ keyword_tokens: keyword_tokens&.map(&:to_s)&.freeze, label_term_tokens: Array(label_term_tokens).map(&:to_s).freeze)
32
+ end
33
+
34
+ def empty?
35
+ filters.empty? && boosts.empty? && intent_vector.empty?
36
+ end
37
+
38
+ # The encoding minus the chips the searcher removed (R20), matched by
39
+ # storage key or by label key.
40
+ def without(suppressed)
41
+ suppressed = Array(suppressed).map(&:to_s).to_set
42
+ return self if suppressed.empty?
43
+
44
+ keep = ->(key, _) { !suppressed.include?(key) && !suppressed.include?(self.class.split_key(key).first) }
45
+ with(filters: filters.select(&keep), boosts: boosts.select(&keep), intent_vector: intent_vector.select(&keep))
46
+ end
47
+
48
+ # Splits a storage key into its label key and choice option. Lens keys
49
+ # ("lens:<id>:<label>[:<option>]") carry two extra colons.
50
+ def self.split_key(key)
51
+ key = key.to_s
52
+ if key.start_with?("#{Lenses::KEY_PREFIX}:")
53
+ prefix, id, label, option = key.split(":", 4)
54
+ [ "#{prefix}:#{id}:#{label}", option ]
55
+ else
56
+ key.split(":", 2)
57
+ end
58
+ end
59
+
60
+ def keywords(query)
61
+ keyword_tokens || (query.tokens - label_term_tokens)
62
+ end
63
+
64
+ # The cache form: decisions plus token positions in the normalized
65
+ # query, so no query text is stored (the cache key already fixes the
66
+ # query).
67
+ def dump(query)
68
+ { "filters" => filters, "boosts" => boosts, "intent_vector" => intent_vector,
69
+ "keyword_positions" => keyword_tokens && positions(query, keyword_tokens),
70
+ "label_term_positions" => positions(query, label_term_tokens) }
71
+ end
72
+
73
+ private
74
+
75
+ def weights(hash)
76
+ hash.to_h.to_h { |key, weight| [ key.to_s, Float(weight) ] }
77
+ end
78
+
79
+ def positions(query, words)
80
+ query.tokens.each_index.select { |index| words.include?(query.tokens[index]) }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,68 @@
1
+ module Truffler
2
+ module Search
3
+ # Where keystroke search finds query encodings and query vectors (R12,
4
+ # R15). Keys digest the model, the normalized query, and the vocabulary
5
+ # version, plus the tenant when the vocabulary has per-tenant choices.
6
+ # Values hold decisions and floats, never query text. Encoding keys use
7
+ # the searcher's vocabulary version, which holds the lenses that searcher
8
+ # can see (KTD21), so a personal lens's encoding is never shared; query
9
+ # vectors do not depend on lenses and stay keyed per tenant.
10
+ #
11
+ # On a miss, search calls `prefetch`, which hands off to
12
+ # `config.encoding_prefetch` (the query encoder, U9). The hook is called
13
+ # as `call(model, query, cache_key:, tenant_key:, user_key:)` and returns
14
+ # truthy when an encoding is now in flight. Without a hook nothing is
15
+ # encoded and the encoding status reads `:none`.
16
+ class EncodingCache
17
+ TTL = 7.days
18
+
19
+ def initialize(store: Truffler.config.cache_store)
20
+ @store = store
21
+ end
22
+
23
+ def key(model, query, tenant_key:, user_key: nil)
24
+ "truffler/enc/#{digest(model, query, tenant_key, user_key)}"
25
+ end
26
+
27
+ def vector_key(model, query, tenant_key:)
28
+ "truffler/vec/#{digest(model, query, tenant_key, nil)}"
29
+ end
30
+
31
+ def read(model, query, tenant_key:, user_key: nil)
32
+ query = Query.wrap(query)
33
+ Encoding.load(@store.read(key(model, query, tenant_key: tenant_key, user_key: user_key)), query)
34
+ end
35
+
36
+ def write(model, query, encoding, tenant_key:, user_key: nil, expires_in: TTL)
37
+ query = Query.wrap(query)
38
+ @store.write(key(model, query, tenant_key: tenant_key, user_key: user_key), encoding.dump(query), expires_in: expires_in)
39
+ end
40
+
41
+ def read_vector(model, query, tenant_key:)
42
+ @store.read(vector_key(model, Query.wrap(query), tenant_key: tenant_key))&.map(&:to_f)
43
+ end
44
+
45
+ def write_vector(model, query, vector, tenant_key:, expires_in: TTL)
46
+ @store.write(vector_key(model, Query.wrap(query), tenant_key: tenant_key), vector.map(&:to_f), expires_in: expires_in)
47
+ end
48
+
49
+ def prefetch(model, query, tenant_key:, user_key:)
50
+ hook = Truffler.config.encoding_prefetch
51
+ return false unless hook
52
+
53
+ query = Query.wrap(query)
54
+ hook.call(model, query, cache_key: key(model, query, tenant_key: tenant_key, user_key: user_key), tenant_key: tenant_key,
55
+ user_key: user_key).present?
56
+ end
57
+
58
+ private
59
+
60
+ def digest(model, query, tenant_key, user_key)
61
+ definition = model.truffler_definition
62
+ tenant = tenant_key&.to_s if definition.per_tenant_vocabulary?
63
+ version = definition.vocabulary.version(tenant_key: tenant_key&.to_s, user_key: user_key)
64
+ Canonical.digest(record_type: model.polymorphic_name, query: query.normalized, vocabulary_version: version, tenant_key: tenant)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,149 @@
1
+ module Truffler
2
+ module Search
3
+ # A tenant-scoped keystroke search (KTD8). It reads the query encoding
4
+ # and query vector from the cache only, so no network call sits on the
5
+ # keystroke (R12); a miss hands the query to the prefetch hook and the
6
+ # results stand without it. Everything else is one SQL query.
7
+ class Keystroke
8
+ DEFAULT_LIMIT = 50
9
+
10
+ attr_reader :model, :query, :tenant_key, :scope, :user_key, :suppressed, :surface, :limit
11
+
12
+ def initialize(model, query, tenant:, scope:, user: nil, suppressed: [], surface: nil, limit: DEFAULT_LIMIT, weights: {},
13
+ cache: EncodingCache.new)
14
+ @model = model
15
+ @definition = model.try(:truffler_definition) || raise(DefinitionError, "#{model.name} has no truffler declaration")
16
+ @query = Query.wrap(query)
17
+ @tenant_key = tenant&.to_s
18
+ @scope = scope.nil? && !@definition.scoped? ? model.all : scope
19
+ @user_key = self.class.user_key(user)
20
+ @suppressed = Array(suppressed).map(&:to_s)
21
+ @surface = surface&.to_s
22
+ @limit = limit
23
+ @weights = @definition.ranking.merge(weights.to_h { |key, weight| [ key.to_sym, Float(weight) ] })
24
+ @cache = cache
25
+ check_scope!
26
+ end
27
+
28
+ def self.user_key(user)
29
+ case user
30
+ when nil then nil
31
+ when ActiveRecord::Base then "#{user.class.polymorphic_name}:#{user.id}"
32
+ else user.to_s
33
+ end
34
+ end
35
+
36
+ def call
37
+ started = Instrumentation.monotonic_ms
38
+ watermark = Time.current
39
+ explicit_action = surface_action
40
+ cached = read_encoding
41
+ status = encoding_status(cached)
42
+ encoding = visible_lenses_only(cached&.without(suppressed), record_usage: true)
43
+ sql = sql(encoding)
44
+ records = sql.relation(scope, limit: limit).to_a
45
+ result = Result.new(records: records, query: query, encoding: encoding, encoding_status: status, watermark: watermark,
46
+ explicit_action: explicit_action, sources: sql.sources, invite_row: invite_row(records, cached),
47
+ weights: @weights, recount: ->(since) { count(since: since) })
48
+ instrument(result, started)
49
+ result
50
+ end
51
+
52
+ # How many records the same search would return that arrived after
53
+ # `since` (R25). Reads the cache only and never prefetches.
54
+ def count(since:)
55
+ sql(visible_lenses_only(read_encoding&.without(suppressed))).candidates(scope)
56
+ .where(model.arel_table[@definition.arrived_at_column].gt(since)).count
57
+ end
58
+
59
+ private
60
+
61
+ def check_scope!
62
+ if @definition.scoped? && tenant_key.nil?
63
+ raise MissingScope, "#{model.name}.truffler needs tenant: (declared tenant #{@definition.tenant_column})"
64
+ end
65
+ return if scope.is_a?(ActiveRecord::Relation) && scope.klass <= model
66
+
67
+ raise MissingScope, "#{model.name}.truffler needs scope: to be a relation of #{model.name}"
68
+ end
69
+
70
+ def surface_action
71
+ return unless surface
72
+
73
+ @definition.surfaces.fetch(surface) { raise DefinitionError, "#{model.name} declares no surface #{surface}" }[:explicit_action]
74
+ end
75
+
76
+ def read_encoding
77
+ return if query.blank? || @definition.labels.empty?
78
+
79
+ @cache.read(model, query, tenant_key: tenant_key, user_key: user_key)
80
+ end
81
+
82
+ # Drops lens keys this searcher cannot see (another user's personal
83
+ # lens, an expired lens) and counts a use of the rest (R42, R43).
84
+ def visible_lenses_only(encoding, record_usage: false)
85
+ lens_keys = encoding ? (encoding.intent_vector.keys | encoding.filters.keys | encoding.boosts.keys).select { |key| lens_id(key) } : []
86
+ return encoding if lens_keys.empty?
87
+
88
+ visible = Lenses.labels(model, tenant_key: tenant_key, user_key: user_key).values.map(&:lens_id).uniq
89
+ hidden, shown = lens_keys.partition { |key| !visible.include?(lens_id(key)) }
90
+ Lenses.record_usage(shown.map { |key| lens_id(key) }.uniq) if record_usage
91
+ encoding.without(hidden)
92
+ end
93
+
94
+ def lens_id(key)
95
+ prefix, id = key.split(":", 3)
96
+ Integer(id, exception: false) if prefix == Lenses::KEY_PREFIX
97
+ end
98
+
99
+ def read_vector
100
+ return if query.blank? || !@definition.embeddings
101
+
102
+ @vector ||= @cache.read_vector(model, query, tenant_key: tenant_key)
103
+ end
104
+
105
+ # Prefetches on a missing encoding or query vector; the status reports
106
+ # the encoding only.
107
+ def encoding_status(cached)
108
+ return :none if query.blank?
109
+
110
+ in_flight = prefetch?(cached) && @cache.prefetch(model, query, tenant_key: tenant_key, user_key: user_key)
111
+ return :cached if cached
112
+ return :none if @definition.labels.empty?
113
+
114
+ in_flight ? :pending : :none
115
+ end
116
+
117
+ def prefetch?(cached)
118
+ (cached.nil? && @definition.labels.any?) || (@definition.embeddings.present? && read_vector.nil?)
119
+ end
120
+
121
+ def sql(encoding)
122
+ Sql.new(model, tenant_key: tenant_key, query: query, encoding: encoding, vector: read_vector, weights: @weights)
123
+ end
124
+
125
+ # R21: the Smart search row. A model with no local text search whose
126
+ # encoding is not cached yet invites the action even when a blind index
127
+ # matched, because intent queries resolve on the action there (AE10).
128
+ def invite_row(records, cached)
129
+ return if query.blank?
130
+
131
+ reason =
132
+ if @definition.keyword.blank? && cached.nil? then :encoding_pending
133
+ elsif records.empty? then :empty
134
+ elsif records.size < @definition.weak_below then :weak
135
+ end
136
+ { query: query.raw.strip, reason: reason } if reason
137
+ end
138
+
139
+ def instrument(result, started)
140
+ payload = { record_type: model.polymorphic_name, tenant_key: tenant_key, surface: surface, outcome: result.encoding_status,
141
+ result_count: result.records.size, filter_count: result.encoding&.filters&.size.to_i,
142
+ boost_count: result.encoding&.intent_vector&.size.to_i, sources: result.sources.map(&:to_s),
143
+ reason: result.invite_row&.dig(:reason), latency_ms: Instrumentation.elapsed_ms(started) }
144
+ payload[:query_digest] = Misses.digest(:query, query.normalized) if Misses.encrypted_model?(model)
145
+ Instrumentation.instrument("search", payload)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,67 @@
1
+ module Truffler
2
+ module Search
3
+ # A normalized, tokenized search query. Quoted phrases stay one token.
4
+ # Exact-text signals (quoted phrases, digit-bearing tokens, emails, and
5
+ # identifier shapes such as `INV-4471` or `order_id`) are detected
6
+ # locally and never asked of Jev (R18).
7
+ class Query
8
+ QUOTED = /"([^"]*)"/
9
+ EMAIL = /\A[^@\s]+@[^@\s]+\.[a-z]{2,}\z/
10
+ IDENTIFIER = /\A[a-z0-9]+(?:[-_.][a-z0-9]+)+\z/
11
+ EDGE_PUNCTUATION = /\A[^\p{Alnum}@]+|[^\p{Alnum}]+\z/
12
+
13
+ attr_reader :raw, :normalized, :tokens, :exact_tokens
14
+
15
+ def self.wrap(query)
16
+ query.is_a?(self) ? query : new(query)
17
+ end
18
+
19
+ def self.normalize(text)
20
+ text.to_s.unicode_normalize(:nfkc).downcase.squish
21
+ end
22
+
23
+ def initialize(raw)
24
+ @raw = raw.to_s
25
+ @normalized = self.class.normalize(raw)
26
+ @tokens, @exact_tokens = tokenize(normalized)
27
+ end
28
+
29
+ def blank?
30
+ tokens.empty?
31
+ end
32
+
33
+ def exact_text?
34
+ exact_tokens.any?
35
+ end
36
+
37
+ private
38
+
39
+ def tokenize(text)
40
+ tokens = []
41
+ exact = []
42
+ text.split(QUOTED, -1).each_with_index do |part, index|
43
+ if index.odd?
44
+ phrase = part.squish
45
+ next if phrase.empty?
46
+
47
+ tokens << phrase
48
+ exact << phrase
49
+ else
50
+ part.split.each do |word|
51
+ word = word.gsub(EDGE_PUNCTUATION, "")
52
+ next if word.empty?
53
+
54
+ tokens << word
55
+ exact << word if exact?(word)
56
+ end
57
+ end
58
+ end
59
+ [ tokens.freeze, exact.freeze ]
60
+ end
61
+
62
+ def exact?(word)
63
+ word.match?(/\d/) || word.match?(EMAIL) || word.match?(IDENTIFIER)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,100 @@
1
+ module Truffler
2
+ module Search
3
+ # One keystroke search, carrying every host UI contract: records, chips
4
+ # (R20), the Smart search invite row (R21), the encoding status, the
5
+ # watermark for "N new matches" (R25), the surface's explicit action
6
+ # (R23), and per-record score breakdowns for debugging.
7
+ class Result
8
+ ENCODING_STATUSES = %i[cached pending none].freeze
9
+ INVITE_REASONS = %i[weak empty encoding_pending].freeze
10
+ SCORE_COLUMNS = { label: "truffler_label_score", text: "truffler_text_score", keyword: "truffler_keyword_score",
11
+ exact: "truffler_exact_score" }.freeze
12
+
13
+ attr_reader :records, :query, :encoding, :encoding_status, :watermark, :explicit_action, :sources, :invite_row
14
+
15
+ def initialize(records:, query:, encoding:, encoding_status:, watermark:, explicit_action:, sources:, invite_row:, weights:, recount:)
16
+ @records = records
17
+ @query = query
18
+ @encoding = encoding
19
+ @encoding_status = encoding_status
20
+ @watermark = watermark
21
+ @explicit_action = explicit_action
22
+ @sources = sources
23
+ @invite_row = invite_row
24
+ @weights = weights
25
+ @recount = recount
26
+ end
27
+
28
+ def ids
29
+ records.map(&:id)
30
+ end
31
+
32
+ # Applied filters, then boosts, as `{key:, label:, kind:, name:}`.
33
+ def chips
34
+ return [] unless encoding
35
+
36
+ filters = encoding.filters.keys.map { |key| chip(key, :filter) }
37
+ filters + (encoding.boosts.keys - encoding.filters.keys).map { |key| chip(key, :boost) }
38
+ end
39
+
40
+ def score(record)
41
+ find(record)&.attributes&.fetch("truffler_score", nil)&.to_f
42
+ end
43
+
44
+ # The weighted terms that summed to the record's score.
45
+ def breakdown(record)
46
+ found = find(record)
47
+ return {} unless found
48
+
49
+ SCORE_COLUMNS.to_h { |term, name| [ term, found.attributes.fetch(name, 0.0).to_f ] }.merge(total: score(found))
50
+ end
51
+
52
+ # Each intent label's share of the label term, `key => w_label * weight
53
+ # * value`. Loaded on first use, so the keystroke itself stays one query.
54
+ def contributions(record)
55
+ id = record.respond_to?(:id) ? record.id : record
56
+ (@contributions ||= load_contributions).fetch(id.to_s, {})
57
+ end
58
+
59
+ def new_matches_count
60
+ @recount.call(watermark)
61
+ end
62
+
63
+ def promoted_ids(run)
64
+ return [] unless run
65
+
66
+ ids & Array(run.promoted_ids)
67
+ end
68
+
69
+ def smart_ranking_paused?(run = nil)
70
+ run&.status&.to_sym == :paused
71
+ end
72
+
73
+ private
74
+
75
+ def find(record)
76
+ id = record.respond_to?(:id) ? record.id : record
77
+ records.find { |item| item.id == id }
78
+ end
79
+
80
+ def chip(key, kind)
81
+ label, option = Encoding.split_key(key)
82
+ title = label.split(":").last.humanize
83
+ name = option ? "#{title}: #{option}" : title
84
+ { key: key, label: label, kind: kind, name: name }
85
+ end
86
+
87
+ def load_contributions
88
+ intent = encoding&.intent_vector.to_h
89
+ return {} if intent.empty? || records.empty?
90
+
91
+ model = records.first.class
92
+ rows = Records::Label.where(record_type: model.polymorphic_name, record_id: ids, label_key: intent.keys)
93
+ .pluck(:record_id, :label_key, :value)
94
+ rows.each_with_object(Hash.new { |hash, id| hash[id] = {} }) do |(id, key, value), map|
95
+ map[id.to_s][key] = @weights[:label] * intent[key] * value
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,197 @@
1
+ module Truffler
2
+ module Search
3
+ # Builds the one keystroke query of KTD8, scored per KTD20:
4
+ #
5
+ # w_label * SUM(weight * value) over the intent's nonzero label keys
6
+ # + w_text * text similarity (inline SQL, or the store's top-K CASE)
7
+ # + w_keyword * keyword hit + w_exact * exact-source hit
8
+ #
9
+ # A weighted dot product, not cosine: cosine would divide out magnitude
10
+ # and let a record high on unrelated labels outrank the one the query
11
+ # asked for. Hard filters are EXISTS subqueries that run before scoring.
12
+ class Sql
13
+ LABELS = "truffler_labels".freeze
14
+
15
+ attr_reader :model, :tenant_key, :query, :encoding, :vector, :weights
16
+
17
+ def initialize(model, tenant_key:, query:, encoding: nil, vector: nil, weights: nil, store: nil)
18
+ @model = model
19
+ @tenant_key = tenant_key&.to_s
20
+ @query = query
21
+ @encoding = encoding || Encoding.new
22
+ @vector = vector
23
+ @weights = weights || definition.ranking
24
+ @store = store
25
+ end
26
+
27
+ # The caller's relation, ANDed with the tenant and the hard filters.
28
+ def base(scope)
29
+ scope = scope.where(definition.tenant_column => tenant_key) if definition.scoped?
30
+ encoding.filters.reduce(scope) { |relation, (key, threshold)| relation.where(Arel.sql(label_filter_sql(key, threshold))) }
31
+ end
32
+
33
+ # Every record the query can return, before ranking.
34
+ def candidates(scope)
35
+ base = base(scope)
36
+ return base if label_only?
37
+
38
+ conditions = [ keyword_sql, exact_sql, (text_candidate_sql if text_score_sql) ].compact
39
+ base.where(Arel.sql(conditions.any? ? conditions.map { |condition| "(#{condition})" }.join(" OR ") : "1 = 0"))
40
+ end
41
+
42
+ def relation(scope, limit: nil)
43
+ relation = candidates(scope)
44
+ relation = relation.select(Arel.sql("#{table}.*")) if relation.select_values.empty?
45
+ relation = relation.select(*score_columns.map { |name, sql| Arel.sql("(#{sql}) AS #{name}") })
46
+ relation = relation.reorder(*ordering)
47
+ limit ? relation.limit(limit) : relation
48
+ end
49
+
50
+ def label_score_sql
51
+ intent = encoding.intent_vector
52
+ return if intent.empty?
53
+
54
+ cases = intent.map { |key, weight| "WHEN #{quote(key)} THEN #{Float(weight)} * #{label_column('value')}" }.join(" ")
55
+ "COALESCE((SELECT SUM(CASE #{label_column('label_key')} #{cases} ELSE 0.0 END) FROM #{quoted_labels} " \
56
+ "WHERE #{label_scope_sql} AND #{label_column('label_key')} IN (#{intent.keys.map { |key| quote(key) }.join(', ')})), 0.0)"
57
+ end
58
+
59
+ def sources
60
+ [ (:keyword if keyword_sql), (:exact if exact_sql), (:vector if text_score_sql),
61
+ (:labels unless encoding.empty?) ].compact
62
+ end
63
+
64
+ def keywords
65
+ encoding.keywords(query)
66
+ end
67
+
68
+ private
69
+
70
+ def definition
71
+ model.truffler_definition
72
+ end
73
+
74
+ def connection
75
+ model.connection
76
+ end
77
+
78
+ def quote(value)
79
+ connection.quote(value)
80
+ end
81
+
82
+ def table
83
+ connection.quote_table_name(model.table_name)
84
+ end
85
+
86
+ def column(name)
87
+ "#{table}.#{connection.quote_column_name(name)}"
88
+ end
89
+
90
+ def primary_key
91
+ column(model.primary_key)
92
+ end
93
+
94
+ def quoted_labels
95
+ connection.quote_table_name(LABELS)
96
+ end
97
+
98
+ def label_column(name)
99
+ "#{quoted_labels}.#{connection.quote_column_name(name)}"
100
+ end
101
+
102
+ def label_scope_sql
103
+ tenant = definition.scoped? ? " AND #{label_column('tenant_key')} = #{quote(tenant_key)}" : ""
104
+ "#{label_column('record_type')} = #{quote(model.polymorphic_name)}#{tenant} AND #{label_column('record_id')} = #{primary_key}"
105
+ end
106
+
107
+ def label_filter_sql(key, threshold)
108
+ "EXISTS (SELECT 1 FROM #{quoted_labels} WHERE #{label_scope_sql} AND #{label_column('label_key')} = #{quote(key)} " \
109
+ "AND #{label_column('value')} >= #{Float(threshold)})"
110
+ end
111
+
112
+ # All label terms (or a blank query) means label-only matches: every
113
+ # record past the hard filters is a candidate, ranked by the label
114
+ # term. So is an applied encoding on a model with nothing local to
115
+ # search its keywords with.
116
+ def label_only?
117
+ return true if keywords.empty?
118
+
119
+ !encoding.empty? && definition.keyword.blank? && !text_score_sql && definition.exact_sources.empty?
120
+ end
121
+
122
+ def keyword_sql
123
+ return @keyword_sql if defined?(@keyword_sql)
124
+
125
+ @keyword_sql = keywords.empty? ? nil : keyword_condition(definition.keyword)
126
+ end
127
+
128
+ def keyword_condition(source)
129
+ case source
130
+ when Array
131
+ return if source.empty?
132
+
133
+ keywords.map do |token|
134
+ pattern = quote("%#{model.sanitize_sql_like(token)}%")
135
+ "(#{source.map { |name| "LOWER(#{column(name)}) LIKE #{pattern} ESCAPE '\\'" }.join(' OR ')})"
136
+ end.join(" AND ")
137
+ when nil then nil
138
+ else membership_sql(source.call(tenant_scope, keywords))
139
+ end
140
+ end
141
+
142
+ def exact_sql
143
+ return @exact_sql if defined?(@exact_sql)
144
+
145
+ values = query.blank? ? [] : (query.exact_tokens + [ query.normalized ]).uniq
146
+ conditions = definition.exact_sources.values.flat_map do |callable|
147
+ values.filter_map { |value| membership_sql(callable.call(tenant_scope, value)) }
148
+ end
149
+ @exact_sql = conditions.empty? ? nil : conditions.join(" OR ")
150
+ end
151
+
152
+ def text_score_sql
153
+ return @text_score_sql if defined?(@text_score_sql)
154
+
155
+ @text_score_sql = (store.similarity_sql(model, tenant_key: tenant_key, vector: vector).to_s if vector && store)
156
+ end
157
+
158
+ def text_candidate_sql
159
+ "#{text_score_sql} > #{Float(weights[:min_similarity])}"
160
+ end
161
+
162
+ def store
163
+ @store ||= Embeddings::VectorStore.for(model) if definition.embeddings
164
+ end
165
+
166
+ def tenant_scope
167
+ definition.scoped? ? model.where(definition.tenant_column => tenant_key) : model.all
168
+ end
169
+
170
+ def membership_sql(result)
171
+ case result
172
+ when ActiveRecord::Relation then "#{primary_key} IN (#{result.reselect(result.klass.arel_table[result.klass.primary_key]).to_sql})"
173
+ when nil then nil
174
+ else
175
+ ids = Array(result)
176
+ "#{primary_key} IN (#{ids.map { |id| quote(id) }.join(', ')})" if ids.any?
177
+ end
178
+ end
179
+
180
+ def score_columns
181
+ terms = {
182
+ truffler_label_score: (label_score_sql && "#{Float(weights[:label])} * #{label_score_sql}"),
183
+ truffler_text_score: (text_score_sql && "#{Float(weights[:text])} * #{text_score_sql}"),
184
+ truffler_keyword_score: (keyword_sql && "#{Float(weights[:keyword])} * (CASE WHEN #{keyword_sql} THEN 1.0 ELSE 0.0 END)"),
185
+ truffler_exact_score: (exact_sql && "#{Float(weights[:exact])} * (CASE WHEN #{exact_sql} THEN 1.0 ELSE 0.0 END)")
186
+ }.compact
187
+ { truffler_score: terms.any? ? terms.values.map { |term| "(#{term})" }.join(" + ") : "0.0", **terms }
188
+ end
189
+
190
+ def ordering
191
+ order_column, direction = definition.order
192
+ [ Arel.sql("truffler_score DESC"), (Arel.sql("#{column(order_column)} #{direction == :asc ? 'ASC' : 'DESC'}") if order_column),
193
+ Arel.sql("#{primary_key} DESC") ].compact
194
+ end
195
+ end
196
+ end
197
+ end