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,161 @@
1
+ module Truffler
2
+ module Lenses
3
+ # A named set of drafted label questions for one model and scope (R39).
4
+ # The row mirrors its active version's questions, so search-time
5
+ # visibility needs no join. Statuses: draft (never activated), proposed
6
+ # (system-drafted from misses, awaiting approval), active, expired.
7
+ class Lens < ActiveRecord::Base
8
+ include SealedDescription
9
+
10
+ self.table_name = "truffler_lenses"
11
+
12
+ STATUSES = %w[draft proposed active expired].freeze
13
+
14
+ HistoryEntry = Data.define(:number, :status, :label_keys, :reused, :restored_from, :created_by_digest, :created_at,
15
+ :activated_by_digest, :activated_at)
16
+
17
+ has_many :versions, -> { order(:number) }, class_name: "Truffler::Lenses::Version", foreign_key: :lens_id,
18
+ inverse_of: :lens, dependent: :delete_all
19
+ belongs_to :active_version, class_name: "Truffler::Lenses::Version", optional: true
20
+ serialize :questions, coder: JSON
21
+ serialize :reused_keys, coder: JSON
22
+
23
+ validates :status, inclusion: { in: STATUSES }
24
+ validates :scope_type, inclusion: { in: %w[app tenant user] }
25
+
26
+ scope :active, -> { where(status: "active") }
27
+ scope :proposed, -> { where(status: "proposed") }
28
+ scope :for_model, ->(model) { where(record_type: model.polymorphic_name) }
29
+
30
+ after_commit { Lenses.changed!(record_type) }
31
+
32
+ def self.build_from(draft, status:, origin: "user", creator_digest: nil, proposal_digest: nil)
33
+ new(record_type: draft.model.polymorphic_name, scope_type: draft.scope.type.to_s, scope_key: draft.scope.scope_key,
34
+ tenant_key: draft.scope.tenant_key, name: draft.name, description: draft.description, status: status,
35
+ origin: origin, creator_digest: creator_digest, proposal_digest: proposal_digest,
36
+ spend_cap_usd: Lenses.settings.spend_cap_usd)
37
+ end
38
+
39
+ # Active lenses nobody has searched with for `after` stop applying
40
+ # (R43). Their stored label rows stay until pruned.
41
+ def self.expire_unused!(now: Time.current, after: Lenses.settings.expire_after)
42
+ unused = active.where("COALESCE(last_used_at, updated_at) < ?", now - after)
43
+ record_types = unused.distinct.pluck(:record_type)
44
+ unused.update_all(status: "expired", updated_at: now).tap { record_types.each { |type| Lenses.changed!(type) } }
45
+ end
46
+
47
+ def model
48
+ record_type.safe_constantize
49
+ end
50
+
51
+ def scope
52
+ Scope.new(type: scope_type.to_sym, tenant_key: tenant_key, user_digest: (scope_key if scope_type == "user"))
53
+ end
54
+
55
+ def active? = status == "active"
56
+ def proposed? = status == "proposed"
57
+ def expired? = status == "expired"
58
+
59
+ # {"lens:<id>:<label>" => wire-shape question} for the active version.
60
+ def storage_questions
61
+ questions.to_h.transform_keys { |label| Lenses.label_key(id, label) }
62
+ end
63
+
64
+ # {"lens:<id>:<label>" => LensLabel} for the active version.
65
+ def labels
66
+ questions.to_h.to_h { |label, question| [ Lenses.label_key(id, label), LensLabel.new(id, label, question) ] }
67
+ end
68
+
69
+ # The truffler_labels keys this lens writes: one per noul or score, one
70
+ # per option for choices.
71
+ def storage_keys
72
+ questions.to_h.flat_map do |label, question|
73
+ key = Lenses.label_key(id, label)
74
+ question["type"] == "choice" ? question["criteria"].keys.map { |option| "#{key}:#{option}" } : [ key ]
75
+ end
76
+ end
77
+
78
+ def remaining_spend
79
+ spend_cap_usd && [ spend_cap_usd - spent_usd.to_f, 0.0 ].max
80
+ end
81
+
82
+ def spend_cap_reached?
83
+ spend_cap_usd.present? && spent_usd.to_f >= spend_cap_usd
84
+ end
85
+
86
+ def would_exceed_cap?(cost)
87
+ spend_cap_usd.present? && spent_usd.to_f + cost > spend_cap_usd
88
+ end
89
+
90
+ # Adds Jev spend atomically, so concurrent previews and backfill jobs
91
+ # never lose an increment.
92
+ def record_spend!(cost)
93
+ return if cost.to_f.zero?
94
+
95
+ self.class.where(id: id).update_all([ "spent_usd = spent_usd + ?, updated_at = ?", cost.to_f, Time.current ])
96
+ self.spent_usd = self.class.where(id: id).pick(:spent_usd)
97
+ end
98
+
99
+ def draft_version
100
+ versions.select(&:draft?).last
101
+ end
102
+
103
+ def add_version!(draft, by:, restored_from: nil)
104
+ versions.create!(number: versions.maximum(:number).to_i + 1, description: draft.description, questions: draft.questions,
105
+ reused_keys: draft.reused, fingerprint: draft.fingerprint, created_by_digest: Policy.digest_for(by),
106
+ status: "draft", restored_from: restored_from)
107
+ end
108
+
109
+ # Drafts a new version from the same or an edited description. Search
110
+ # keeps using the active version until the draft is activated.
111
+ def regenerate(by:, description: nil, drafter: Drafter.new)
112
+ Policy.authorize!(by, scope, model: model)
113
+ text = description.presence || self.description
114
+ raise InvalidLens, "lens #{id} has no readable description; pass description:" if text.blank?
115
+
116
+ add_version!(drafter.draft(text, model: model, scope: scope, lens: self), by: by)
117
+ end
118
+
119
+ # Makes an earlier version's questions active again as a new version
120
+ # that records where it came from, so history keeps every change.
121
+ def restore!(number, by:)
122
+ source = versions.find_by!(number: number)
123
+ Policy.authorize!(by, scope, model: model)
124
+ Activator.activate(add_version!(source.to_draft, by: by, restored_from: source.number), by: by)
125
+ end
126
+
127
+ def history
128
+ versions.reload.map do |version|
129
+ HistoryEntry.new(number: version.number, status: version.status, label_keys: version.questions.to_h.keys,
130
+ reused: Array(version.reused_keys), restored_from: version.restored_from,
131
+ created_by_digest: version.created_by_digest, created_at: version.created_at,
132
+ activated_by_digest: version.activated_by_digest, activated_at: version.activated_at)
133
+ end
134
+ end
135
+
136
+ # The declaration a developer pastes into the model to make this lens
137
+ # part of the declared vocabulary (R43).
138
+ def declaration_snippet
139
+ questions.to_h.map do |label, question|
140
+ line = "label :#{label}, :#{question['type']}, question: #{question['instructions'].to_s.inspect}"
141
+ case question["type"]
142
+ when "noul" then question["criteria"] ? "#{line}, criteria: #{question['criteria'].inspect}" : line
143
+ when "choice" then "#{line}, options: #{question['criteria'].inspect}"
144
+ when "score" then "#{line}, legend: #{question['criteria'].inspect}"
145
+ else raise InvalidLens, "#{label}: unknown question type #{question['type'].inspect}"
146
+ end
147
+ end.join("\n")
148
+ end
149
+
150
+ def promote!(io: $stdout)
151
+ declaration_snippet.tap { |snippet| io.puts(snippet) }
152
+ end
153
+
154
+ private
155
+
156
+ def described_model
157
+ model
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,51 @@
1
+ module Truffler
2
+ module Lenses
3
+ # One lens question behind the LabelDefinition interface, so the labeler,
4
+ # request builder, label vectors, and query encoding treat it like a
5
+ # declared label. `key` is its storage key ("lens:<id>:<label>"); in Jev
6
+ # requests it is asked as "lens<id>__<label>", which cannot collide with a
7
+ # declared key because declared keys never hold a double underscore.
8
+ class LensLabel
9
+ attr_reader :lens_id, :label, :key, :type, :instructions
10
+
11
+ def initialize(lens_id, label, question)
12
+ @lens_id = lens_id
13
+ @label = label.to_s
14
+ @question = question.deep_dup.freeze
15
+ @key = Lenses.label_key(lens_id, @label)
16
+ @type = question.fetch("type").to_sym
17
+ @instructions = question["instructions"]
18
+ end
19
+
20
+ def question_key
21
+ "#{KEY_PREFIX}#{lens_id}#{Questions::SEPARATOR}#{label}"
22
+ end
23
+
24
+ def question(_tenant_key = nil)
25
+ @question.deep_dup
26
+ end
27
+
28
+ def options(_tenant_key = nil)
29
+ @question["criteria"].to_h.transform_keys(&:to_s)
30
+ end
31
+
32
+ def storage_keys(tenant_key = nil)
33
+ type == :choice ? options(tenant_key).keys.map { |option| "#{key}:#{option}" } : [ key ]
34
+ end
35
+
36
+ def per_tenant?
37
+ false
38
+ end
39
+
40
+ def supplied?
41
+ false
42
+ end
43
+
44
+ def description = instructions
45
+
46
+ def filter_at = nil
47
+ def boost = nil
48
+ def filter_weight = 0.0
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,46 @@
1
+ module Truffler
2
+ module Lenses
3
+ # Who may create, regenerate, activate, and restore lenses (R42).
4
+ #
5
+ # `config.lenses.creators` bounds the scopes a lens may have:
6
+ # :developers - any scope, for users the host hook marks as developers or admins
7
+ # :tenant_users - tenant or personal lenses, for users of that tenant
8
+ # :each_user - personal lenses only, for their own creator
9
+ # The host hook `config.lenses.authorize_lens.call(user, scope)` always has
10
+ # the final word; without one, every change is refused.
11
+ module Policy
12
+ SCOPES = { developers: %i[app tenant user], tenant_users: %i[tenant user], each_user: %i[user] }.freeze
13
+
14
+ module_function
15
+
16
+ def authorize!(user, scope, model: nil, settings: Lenses.settings)
17
+ allowed = SCOPES.fetch(settings.creators)
18
+ unless allowed.include?(scope.type)
19
+ raise NotAuthorized, "#{scope.type} lenses are not allowed when lens creators are #{settings.creators}"
20
+ end
21
+ if settings.creators == :each_user && scope.user_digest != digest_for(user, settings: settings)
22
+ raise NotAuthorized, "a personal lens can only be changed by its own user"
23
+ end
24
+
25
+ hook = settings.authorize_lens
26
+ raise NotAuthorized, "configure config.lenses.authorize_lens to allow lens changes" unless hook
27
+ raise NotAuthorized, "not allowed to change #{scope.type} lenses#{" on #{model.name}" if model}" unless hook.call(user, scope)
28
+
29
+ true
30
+ end
31
+
32
+ def allowed?(user, scope, **options)
33
+ authorize!(user, scope, **options)
34
+ rescue NotAuthorized
35
+ false
36
+ end
37
+
38
+ def digest_for(user, settings: Lenses.settings)
39
+ return if user.nil?
40
+
41
+ key = settings.key_for(user)
42
+ Lenses.digest(key) if key.present?
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,217 @@
1
+ module Truffler
2
+ module Lenses
3
+ # Answers a draft's questions on a sample of recent in-scope records in
4
+ # one packed single-tenant Jev request, at encode priority and inside the
5
+ # lens's spend cap (R40). `compare` runs an old and a new version on the
6
+ # same sample and reports how the answers moved (R45).
7
+ #
8
+ # A preview holds record ids and numbers only; record text exists only in
9
+ # the request state while the call runs.
10
+ class Previewer
11
+ MAX_EXAMPLES = 5
12
+
13
+ Preview = Data.define(:tenant_key, :sample_ids, :types, :values, :buckets, :distribution, :means, :examples,
14
+ :estimate, :cost)
15
+ Estimate = Data.define(:records, :requests, :input_tokens, :cost_usd, :duration_seconds, :within_cap)
16
+ Comparison = Data.define(:before, :after, :shift, :changed_count, :changed_ids, :added, :removed)
17
+
18
+ def self.preview(target, **options)
19
+ new.preview(target, **options)
20
+ end
21
+
22
+ def self.compare(draft, active, **options)
23
+ new.compare(draft, active, **options)
24
+ end
25
+
26
+ attr_reader :client, :budget, :config
27
+
28
+ def initialize(client: Truffler.config.client, budget: Budget.new, config: Truffler.config)
29
+ @client = client
30
+ @budget = budget
31
+ @config = config
32
+ end
33
+
34
+ # target: a Draft, a Version, or a Lens (its active version). relation
35
+ # narrows the sample to what the host's permission scope allows.
36
+ def preview(target, sample: config.lenses.sample_size, ids: nil, tenant_key: nil, relation: nil, by: nil)
37
+ draft = coerce(target)
38
+ Validation.validate!(draft.questions, reused: draft.reused)
39
+ scope = relation || draft.model.all
40
+ tenant_key = resolve_tenant(draft, scope, tenant_key)
41
+ records = sample_records(draft, scope, tenant_key, sample, ids)
42
+ return empty_preview(draft, scope, tenant_key, records) if records.empty? || draft.questions.empty?
43
+
44
+ state, questions, tags = build(draft, records)
45
+ request_tokens = Tokens.estimate({ state: state, questions: questions })
46
+ check_cap!(draft, config.cost_for(request_tokens))
47
+ decision = budget.acquire(priority: :encode, user_key: (config.lenses.key_for(by) if by))
48
+ raise BudgetExhausted, "no Jev budget for a lens preview" unless decision.granted?
49
+
50
+ answers = client.ask(state: state, questions: questions, priority: :encode)
51
+ cost = answers.usage&.cost.to_f
52
+ draft.lens&.record_spend!(cost)
53
+ summarize(draft, scope, tenant_key, tags, answers, request_tokens, cost)
54
+ end
55
+
56
+ def compare(draft, active, sample: config.lenses.sample_size, tenant_key: nil, relation: nil, by: nil)
57
+ before = preview(active, sample: sample, tenant_key: tenant_key, relation: relation, by: by)
58
+ after = preview(draft, ids: before.sample_ids, tenant_key: before.tenant_key, relation: relation, by: by)
59
+ shared = before.types.keys.select { |label| after.types[label] == before.types[label] }
60
+ common = before.sample_ids & after.sample_ids
61
+ changed = common.select { |id| shared.any? { |label| before.buckets.dig(id, label) != after.buckets.dig(id, label) } }
62
+
63
+ Comparison.new(before: before, after: after, shift: shared.index_with { |label| shift(before, after, label, common) },
64
+ changed_count: changed.size, changed_ids: changed, added: after.types.keys - shared, removed: before.types.keys - shared)
65
+ end
66
+
67
+ private
68
+
69
+ def coerce(target)
70
+ case target
71
+ when Draft then target
72
+ when Version then target.to_draft
73
+ when Lens then target.active_version&.to_draft || raise(InvalidLens, "lens #{target.id} has no active version")
74
+ else raise ArgumentError, "cannot preview #{target.class.name}"
75
+ end
76
+ end
77
+
78
+ def definition(draft)
79
+ draft.model.truffler_definition
80
+ end
81
+
82
+ def resolve_tenant(draft, scope, tenant_key)
83
+ tenant_key ||= draft.scope.tenant_key
84
+ return tenant_key&.to_s if tenant_key || !definition(draft).scoped?
85
+
86
+ scope.reorder(definition(draft).arrival_order).pick(definition(draft).tenant_column)&.to_s
87
+ end
88
+
89
+ def in_tenant(draft, scope, tenant_key)
90
+ definition(draft).scoped? ? scope.where(definition(draft).tenant_column => tenant_key) : scope
91
+ end
92
+
93
+ def sample_records(draft, scope, tenant_key, sample, ids)
94
+ model = draft.model
95
+ records = in_tenant(draft, scope, tenant_key)
96
+ records = records.where(model.primary_key => ids) if ids
97
+ per_request = [ config.max_questions_per_request / [ draft.questions.size, 1 ].max, 1 ].max
98
+ records.reorder(definition(draft).arrival_order).limit([ ids&.size || sample, per_request ].min).to_a
99
+ end
100
+
101
+ def build(draft, records)
102
+ state_records = {}
103
+ questions = {}
104
+ tags = {}
105
+ used = Tokens.estimate(Labeling::RequestBuilder::TASK)
106
+ records.each.with_index(1) do |record, index|
107
+ tag = Questions.tag("r", index)
108
+ fields = definition(draft).request_fields(record, max_chars: config.max_field_chars)
109
+ asked = draft.questions.to_h do |label, question|
110
+ [ Questions.tagged_id(tag, label), question.merge("instructions" => { "record" => tag, "question" => question["instructions"] }) ]
111
+ end
112
+ used += Tokens.estimate(fields) + Tokens.estimate(asked)
113
+ break if tags.any? && used > config.request_token_budget
114
+
115
+ state_records[tag] = fields
116
+ questions.merge!(asked)
117
+ tags[tag] = record.id
118
+ end
119
+ [ { "task" => Labeling::RequestBuilder::TASK, "records" => state_records }, questions, tags ]
120
+ end
121
+
122
+ def check_cap!(draft, estimate)
123
+ remaining = remaining_cap(draft)
124
+ return if remaining.nil? || estimate <= remaining
125
+
126
+ raise LensSpendCapExceeded, format("a lens preview needs about $%.6f but the lens has $%.6f left", estimate, remaining)
127
+ end
128
+
129
+ def remaining_cap(draft)
130
+ draft.lens ? draft.lens.remaining_spend : config.lenses.spend_cap_usd
131
+ end
132
+
133
+ def summarize(draft, scope, tenant_key, tags, answers, request_tokens, cost)
134
+ types = draft.question_types
135
+ values = {}
136
+ buckets = {}
137
+ tags.each do |tag, record_id|
138
+ values[record_id] = {}
139
+ buckets[record_id] = {}
140
+ draft.questions.each do |label, question|
141
+ record_values, bucket = answer(label, question, answers, Questions.tagged_id(tag, label))
142
+ values[record_id].merge!(record_values)
143
+ buckets[record_id][label] = bucket
144
+ end
145
+ end
146
+
147
+ distribution = draft.questions.to_h do |label, question|
148
+ counts = bucket_names(question).index_with(0)
149
+ buckets.each_value { |record| counts[record[label]] = counts[record[label]].to_i + 1 }
150
+ [ label, counts ]
151
+ end
152
+ means = types.reject { |_, type| type == "choice" }.to_h do |label, _|
153
+ [ label, values.values.sum { |record| record[label] } / values.size ]
154
+ end
155
+ examples = draft.questions.keys.index_with do |label|
156
+ buckets.group_by { |_, record| record[label] }.transform_values { |rows| rows.first(MAX_EXAMPLES).map(&:first) }
157
+ end
158
+
159
+ Preview.new(tenant_key: tenant_key, sample_ids: tags.values, types: types, values: values, buckets: buckets,
160
+ distribution: distribution, means: means, examples: examples, cost: cost,
161
+ estimate: estimate(draft, scope, tenant_key, request_tokens / tags.size.to_f, cost))
162
+ end
163
+
164
+ # Returns [{storage suffix => value}, bucket]. Storage suffixes match
165
+ # the label rows a lens writes: "label" or "label:option".
166
+ def answer(label, question, answers, id)
167
+ case question["type"]
168
+ when "noul"
169
+ value = answers.noul(id)
170
+ [ { label => value }, value >= 0.5 ? "yes" : "no" ]
171
+ when "score"
172
+ value = answers.score(id)
173
+ [ { label => value }, (value * (question["criteria"].size - 1)).round.to_s ]
174
+ when "choice"
175
+ options = question["criteria"].keys
176
+ [ options.to_h { |option| [ "#{label}:#{option}", answers.probability(id, option) ] }, answers.choice(id) ]
177
+ else raise InvalidLens, "#{label}: unknown question type #{question['type'].inspect}"
178
+ end
179
+ end
180
+
181
+ def bucket_names(question)
182
+ case question["type"]
183
+ when "noul" then %w[yes no]
184
+ when "score" then question["criteria"].each_index.map(&:to_s)
185
+ when "choice" then question["criteria"].keys
186
+ else raise InvalidLens, "unknown question type #{question['type'].inspect}"
187
+ end
188
+ end
189
+
190
+ # Backfill estimate for every record the lens applies to: records ×
191
+ # tokens per record × price, and requests at the backfill ceiling.
192
+ def estimate(draft, scope, tenant_key, tokens_per_record, preview_cost)
193
+ records = (draft.scope.app? ? scope : in_tenant(draft, scope, tenant_key)).count
194
+ tokens = (records * tokens_per_record).ceil
195
+ cost = config.cost_for(tokens)
196
+ requests = (records / config.batch_size.to_f).ceil
197
+ remaining = remaining_cap(draft)
198
+ remaining -= preview_cost if remaining && !draft.lens
199
+ Estimate.new(records: records, requests: requests, input_tokens: tokens, cost_usd: cost,
200
+ duration_seconds: (requests / budget.ceiling(:backfill)).ceil, within_cap: remaining.nil? || cost <= remaining)
201
+ end
202
+
203
+ def empty_preview(draft, scope, tenant_key, records)
204
+ Preview.new(tenant_key: tenant_key, sample_ids: records.map(&:id), types: draft.question_types, values: {}, buckets: {},
205
+ distribution: draft.questions.transform_values { |question| bucket_names(question).index_with(0) }, means: {},
206
+ examples: {}, cost: 0.0, estimate: estimate(draft, scope, tenant_key, 0, 0.0))
207
+ end
208
+
209
+ def shift(before, after, label, ids)
210
+ names = before.distribution[label].keys | after.distribution[label].keys
211
+ buckets = names.index_with { |name| after.distribution[label][name].to_i - before.distribution[label][name].to_i }
212
+ mean = after.means[label] - before.means[label] if before.means.key?(label) && after.means.key?(label)
213
+ { buckets: buckets, mean: mean, changed: ids.count { |id| before.buckets.dig(id, label) != after.buckets.dig(id, label) } }
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,53 @@
1
+ module Truffler
2
+ module Lenses
3
+ # Drafts lenses from query-miss clusters that passed the distinct-user
4
+ # gate (R28, R29, R42). Proposals are stored as `proposed` and change
5
+ # nothing until someone the policy allows activates them. The drafting
6
+ # model sees only aggregated cluster terms and counts.
7
+ class Proposer
8
+ def self.propose(model, **options)
9
+ new.propose(model, **options)
10
+ end
11
+
12
+ def initialize(drafter: Drafter.new, settings: Lenses.settings)
13
+ @drafter = drafter
14
+ @settings = settings
15
+ end
16
+
17
+ # tenant_key: one tenant's misses become tenant proposals;
18
+ # Misses::ALL_TENANTS pools every tenant into app proposals.
19
+ def propose(model, tenant_key:)
20
+ return [] unless @settings.proposals
21
+
22
+ model = Misses.resolve(model)
23
+ scope = tenant_key == Misses::ALL_TENANTS ? Scope.app : Scope.tenant(tenant_key)
24
+ Misses.clusters(model, tenant_key: tenant_key).filter_map do |cluster|
25
+ proposal_digest = Canonical.digest(terms: cluster.terms.sort)
26
+ next if existing?(model, scope, proposal_digest)
27
+
28
+ draft = @drafter.draft(description_for(model, cluster), model: model, scope: scope, clusters: [ cluster ])
29
+ create(draft, proposal_digest)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def existing?(model, scope, proposal_digest)
36
+ Lens.for_model(model).where(scope_type: scope.type.to_s, tenant_key: scope.tenant_key, proposal_digest: proposal_digest).exists?
37
+ end
38
+
39
+ def description_for(model, cluster)
40
+ "#{model.model_name.human.downcase.pluralize} about #{cluster.terms.to_sentence}"
41
+ end
42
+
43
+ def create(draft, proposal_digest)
44
+ Lens.transaction do
45
+ lens = Lens.build_from(draft, status: "proposed", origin: "proposal", proposal_digest: proposal_digest).tap(&:save!)
46
+ lens.add_version!(draft, by: nil)
47
+ Instrumentation.instrument(:lens_proposed, record_type: lens.record_type, tenant_key: lens.tenant_key, lens_id: lens.id)
48
+ lens
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,42 @@
1
+ require "ruby_llm"
2
+
3
+ module Truffler
4
+ module Lenses
5
+ # The default drafting model: `RubyLLM.chat(...).with_schema(...)`, which
6
+ # ruby_llm 1.x and 2 share. 1.x parses structured output into `content`;
7
+ # 2 keeps `content` as text and parses it in `parsed`. ruby_llm is not a
8
+ # truffler dependency; this file loads only when the default is used.
9
+ class RubyLLMGenerator
10
+ def generate(prompt:, schema:, model: nil)
11
+ message = chat(**{ model: model }.compact).with_schema(schema).ask(prompt)
12
+ { draft: parsed(message), model: model_of(message), input_tokens: input_tokens(message) }
13
+ end
14
+
15
+ private
16
+
17
+ def chat(**options)
18
+ RubyLLM.chat(**options)
19
+ end
20
+
21
+ def parsed(message)
22
+ body = message.respond_to?(:parsed) ? message.parsed : message.content
23
+ body = JSON.parse(body) if body.is_a?(String)
24
+ raise InvalidLens, "the drafting model returned no structured draft" unless body.is_a?(Hash)
25
+
26
+ body
27
+ rescue JSON::ParserError
28
+ raise InvalidLens, "the drafting model returned malformed JSON"
29
+ end
30
+
31
+ def model_of(message)
32
+ (message.respond_to?(:model_id) && message.model_id) || (message.respond_to?(:model) && message.model) || nil
33
+ end
34
+
35
+ def input_tokens(message)
36
+ return message.input_tokens if message.respond_to?(:input_tokens) && message.input_tokens
37
+
38
+ message.tokens&.input if message.respond_to?(:tokens)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,35 @@
1
+ module Truffler
2
+ module Lenses
3
+ # What a lens applies to: the whole app, one tenant, or one user inside a
4
+ # tenant. User keys are held only as keyed digests.
5
+ Scope = Data.define(:type, :tenant_key, :user_digest) do
6
+ def self.app
7
+ new(type: :app, tenant_key: nil, user_digest: nil)
8
+ end
9
+
10
+ def self.tenant(tenant_key)
11
+ raise ArgumentError, "a tenant lens needs a tenant key" if tenant_key.blank?
12
+
13
+ new(type: :tenant, tenant_key: tenant_key.to_s, user_digest: nil)
14
+ end
15
+
16
+ def self.user(tenant_key, user_key)
17
+ raise ArgumentError, "a user lens needs a user key" if user_key.blank?
18
+
19
+ new(type: :user, tenant_key: tenant_key&.to_s, user_digest: Lenses.digest(user_key))
20
+ end
21
+
22
+ def app? = type == :app
23
+ def tenant? = type == :tenant
24
+ def user? = type == :user
25
+
26
+ def scope_key
27
+ case type
28
+ when :app then nil
29
+ when :tenant then tenant_key
30
+ when :user then user_digest
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,29 @@
1
+ module Truffler
2
+ module Lenses
3
+ # Stores `description` through Lenses.seal, so encrypted models keep only
4
+ # ciphertext (or nothing) in the lens tables (R44).
5
+ module SealedDescription
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ before_save :seal_description
10
+ end
11
+
12
+ def description
13
+ return @description_text if defined?(@description_text)
14
+
15
+ Lenses.unseal(described_model, read_attribute(:description))
16
+ end
17
+
18
+ def description=(text)
19
+ @description_text = text
20
+ end
21
+
22
+ private
23
+
24
+ def seal_description
25
+ write_attribute(:description, Lenses.seal(described_model, @description_text)) if defined?(@description_text)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,45 @@
1
+ module Truffler
2
+ module Lenses
3
+ # `config.lenses`: who may create lenses, whether the system proposes them
4
+ # from query misses, and the limits every lens runs under (R42, R43).
5
+ #
6
+ # config.lenses.creators = :tenant_users
7
+ # config.lenses.authorize_lens = ->(user, scope) { user.admin? || scope.tenant_key == user.account_id.to_s }
8
+ class Settings
9
+ CREATORS = %i[developers tenant_users each_user].freeze
10
+
11
+ attr_reader :creators
12
+ attr_accessor :proposals, :expire_after, :spend_cap_usd, :sample_size, :max_questions, :generator,
13
+ :drafter_model, :authorize_lens, :user_key
14
+
15
+ def initialize
16
+ @creators = :developers
17
+ @proposals = false
18
+ @expire_after = 30.days
19
+ @spend_cap_usd = 1.0
20
+ @sample_size = 20
21
+ @max_questions = 8
22
+ @generator = nil
23
+ @drafter_model = nil
24
+ @authorize_lens = nil
25
+ @user_key = ->(user) { user.is_a?(ActiveRecord::Base) ? Search::Keystroke.user_key(user) : (user.respond_to?(:id) ? user.id : user) }
26
+ end
27
+
28
+ def creators=(value)
29
+ value = value.to_sym
30
+ raise ArgumentError, "config.lenses.creators must be one of #{CREATORS.join(', ')}" unless CREATORS.include?(value)
31
+
32
+ @creators = value
33
+ end
34
+
35
+ def generator_or_default
36
+ generator || RubyLLMGenerator.new
37
+ end
38
+
39
+ def key_for(user)
40
+ key = user_key.call(user)
41
+ key.nil? ? nil : key.to_s
42
+ end
43
+ end
44
+ end
45
+ end