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