automatic 14.12.2 → 26.09

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 (177) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +669 -83
  3. data/VERSION +1 -1
  4. data/assets/siteinfo/items_all.json +60300 -52138
  5. data/automatic.gemspec +110 -248
  6. data/bin/automatic +20 -139
  7. data/config/feed2console.yml +10 -4
  8. data/config/feed2markdown.yml +41 -0
  9. data/doc/AI_TUTORIAL.md +542 -0
  10. data/doc/BASIC_DESIGN.md +547 -0
  11. data/doc/COPYING.LESSER +165 -0
  12. data/doc/DEPLOYMENT.md +830 -0
  13. data/doc/LICENSE.md +14 -0
  14. data/doc/PLUGINS.md +2015 -0
  15. data/doc/PLUGIN_DEVELOPMENT.md +86 -0
  16. data/doc/POLICY.md +962 -0
  17. data/doc/QUICKSTART.md +260 -0
  18. data/doc/RELEASING.md +394 -0
  19. data/doc/REQUIREMENTS.md +556 -0
  20. data/doc/VERSIONS +266 -0
  21. data/lib/automatic/cli.rb +270 -0
  22. data/lib/automatic/environment.rb +31 -5
  23. data/lib/automatic/feed_maker.rb +65 -25
  24. data/lib/automatic/feed_parser.rb +51 -35
  25. data/lib/automatic/http.rb +107 -0
  26. data/lib/automatic/log.rb +49 -18
  27. data/lib/automatic/opml.rb +3 -1
  28. data/lib/automatic/pipeline.rb +73 -32
  29. data/lib/automatic/recipe.rb +100 -17
  30. data/lib/automatic/version.rb +14 -1
  31. data/lib/automatic.rb +93 -20
  32. data/plugins/custom_feed/svn_log.rb +73 -32
  33. data/plugins/custom_feed/web.rb +348 -0
  34. data/plugins/filter/absolute_uri.rb +43 -27
  35. data/plugins/filter/accept.rb +38 -45
  36. data/plugins/filter/batch.rb +97 -0
  37. data/plugins/filter/claude.rb +217 -0
  38. data/plugins/filter/clear.rb +12 -8
  39. data/plugins/filter/description_link.rb +66 -51
  40. data/plugins/filter/full_feed.rb +170 -52
  41. data/plugins/filter/gemini.rb +216 -0
  42. data/plugins/filter/github_feed.rb +38 -26
  43. data/plugins/filter/ignore.rb +33 -44
  44. data/plugins/filter/image.rb +36 -25
  45. data/plugins/filter/image_source.rb +75 -52
  46. data/plugins/filter/join.rb +105 -0
  47. data/plugins/filter/kimi.rb +216 -0
  48. data/plugins/filter/limit.rb +57 -0
  49. data/plugins/filter/one.rb +19 -26
  50. data/plugins/filter/open_ai.rb +198 -0
  51. data/plugins/filter/present.rb +75 -0
  52. data/plugins/filter/rand.rb +16 -17
  53. data/plugins/filter/sakura_ai.rb +205 -0
  54. data/plugins/filter/sanitize.rb +29 -34
  55. data/plugins/filter/sort.rb +20 -27
  56. data/plugins/filter/tumblr_resize.rb +31 -23
  57. data/plugins/notify/ikachan.rb +86 -48
  58. data/plugins/provide/fluentd.rb +43 -24
  59. data/plugins/publish/amazon_s3.rb +79 -40
  60. data/plugins/publish/console.rb +19 -16
  61. data/plugins/publish/console_link.rb +20 -17
  62. data/plugins/publish/eject.rb +48 -26
  63. data/plugins/publish/fluentd.rb +50 -30
  64. data/plugins/publish/hatena_bookmark.rb +88 -71
  65. data/plugins/publish/instapaper.rb +69 -59
  66. data/plugins/publish/markdown.rb +278 -0
  67. data/plugins/publish/memcached.rb +35 -29
  68. data/plugins/store/database.rb +50 -48
  69. data/plugins/store/digest.rb +212 -0
  70. data/plugins/store/file.rb +107 -68
  71. data/plugins/store/full_text.rb +31 -28
  72. data/plugins/store/permalink.rb +18 -22
  73. data/plugins/subscription/feed.rb +34 -21
  74. data/plugins/subscription/link.rb +31 -32
  75. data/plugins/subscription/text.rb +40 -45
  76. data/plugins/subscription/tumblr.rb +55 -44
  77. data/plugins/subscription/xml.rb +40 -36
  78. metadata +112 -321
  79. data/Gemfile +0 -38
  80. data/Rakefile +0 -59
  81. data/doc/ChangeLog +0 -303
  82. data/doc/PLUGINS +0 -750
  83. data/doc/PLUGINS.ja +0 -753
  84. data/doc/README +0 -511
  85. data/doc/README.ja +0 -519
  86. data/plugins/filter/google_news.rb +0 -50
  87. data/plugins/publish/google_calendar.rb +0 -84
  88. data/plugins/publish/hipchat.rb +0 -46
  89. data/plugins/publish/pocket.rb +0 -45
  90. data/plugins/publish/twitter.rb +0 -58
  91. data/plugins/subscription/chan_toru.rb +0 -57
  92. data/plugins/subscription/g_guide.rb +0 -57
  93. data/plugins/subscription/pocket.rb +0 -51
  94. data/plugins/subscription/twitter.rb +0 -69
  95. data/plugins/subscription/twitter_search.rb +0 -50
  96. data/plugins/subscription/weather.rb +0 -33
  97. data/script/build +0 -84
  98. data/spec/fixtures/sampleFeeds.tsv +0 -1
  99. data/spec/fixtures/sampleFeeds2.tsv +0 -2
  100. data/spec/fixtures/sampleRecipe.yml +0 -24
  101. data/spec/lib/automatic/log_spec.rb +0 -32
  102. data/spec/lib/automatic/pipeline_spec.rb +0 -68
  103. data/spec/lib/automatic/recipe_spec.rb +0 -40
  104. data/spec/lib/automatic_spec.rb +0 -99
  105. data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
  106. data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
  107. data/spec/plugins/filter/accept_spec.rb +0 -331
  108. data/spec/plugins/filter/clear_spec.rb +0 -49
  109. data/spec/plugins/filter/description_link_spec.rb +0 -138
  110. data/spec/plugins/filter/full_feed_spec.rb +0 -129
  111. data/spec/plugins/filter/github_feed_spec.rb +0 -55
  112. data/spec/plugins/filter/google_news_spec.rb +0 -69
  113. data/spec/plugins/filter/ignore_spec.rb +0 -328
  114. data/spec/plugins/filter/image_source_spec.rb +0 -89
  115. data/spec/plugins/filter/image_spec.rb +0 -65
  116. data/spec/plugins/filter/one_spec.rb +0 -71
  117. data/spec/plugins/filter/rand_spec.rb +0 -52
  118. data/spec/plugins/filter/sanitize_spec.rb +0 -153
  119. data/spec/plugins/filter/sort_spec.rb +0 -189
  120. data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
  121. data/spec/plugins/notify/ikachan_spec.rb +0 -58
  122. data/spec/plugins/provide/fluentd_spec.rb +0 -49
  123. data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
  124. data/spec/plugins/publish/console_spec.rb +0 -30
  125. data/spec/plugins/publish/eject_spec.rb +0 -40
  126. data/spec/plugins/publish/fluentd_spec.rb +0 -40
  127. data/spec/plugins/publish/google_calendar_spec.rb +0 -83
  128. data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
  129. data/spec/plugins/publish/hipchat_spec.rb +0 -69
  130. data/spec/plugins/publish/instapaper_spec.rb +0 -82
  131. data/spec/plugins/publish/memcached_spec.rb +0 -63
  132. data/spec/plugins/publish/pocket_spec.rb +0 -51
  133. data/spec/plugins/publish/twitter_spec.rb +0 -73
  134. data/spec/plugins/store/file_spec.rb +0 -58
  135. data/spec/plugins/store/full_text_spec.rb +0 -152
  136. data/spec/plugins/store/permalink_spec.rb +0 -206
  137. data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
  138. data/spec/plugins/subscription/feed_spec.rb +0 -71
  139. data/spec/plugins/subscription/g_guide_spec.rb +0 -82
  140. data/spec/plugins/subscription/link_spec.rb +0 -72
  141. data/spec/plugins/subscription/pocket_spec.rb +0 -57
  142. data/spec/plugins/subscription/text_spec.rb +0 -84
  143. data/spec/plugins/subscription/tumblr_spec.rb +0 -74
  144. data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
  145. data/spec/plugins/subscription/twitter_spec.rb +0 -73
  146. data/spec/plugins/subscription/weather_spec.rb +0 -44
  147. data/spec/plugins/subscription/xml_spec.rb +0 -84
  148. data/spec/spec_helper.rb +0 -106
  149. data/spec/user_dir/plugins/store/mock.rb +0 -16
  150. data/test/fixtures/sampleOPML.xml +0 -11
  151. data/test/integration/test_absoluteurl.yml +0 -25
  152. data/test/integration/test_activerecord.yml +0 -24
  153. data/test/integration/test_add_pocket.yml +0 -26
  154. data/test/integration/test_chan_toru.yml +0 -21
  155. data/test/integration/test_descriptionlink.yml +0 -21
  156. data/test/integration/test_fluentd.yml +0 -22
  157. data/test/integration/test_fulltext.yml +0 -30
  158. data/test/integration/test_google_news.yml +0 -21
  159. data/test/integration/test_googlealert.yml +0 -21
  160. data/test/integration/test_hatenabookmark.yml +0 -30
  161. data/test/integration/test_ignore.yml +0 -25
  162. data/test/integration/test_ignore2.yml +0 -22
  163. data/test/integration/test_image2local.yml +0 -33
  164. data/test/integration/test_instapaper.yml +0 -26
  165. data/test/integration/test_link2local.yml +0 -34
  166. data/test/integration/test_one.yml +0 -23
  167. data/test/integration/test_pocket.yml +0 -22
  168. data/test/integration/test_rand.yml +0 -21
  169. data/test/integration/test_sanitize.yml +0 -23
  170. data/test/integration/test_sort.yml +0 -36
  171. data/test/integration/test_svnlog.yml +0 -15
  172. data/test/integration/test_text2feed.yml +0 -36
  173. data/test/integration/test_tumblr2local.yml +0 -43
  174. data/test/integration/test_twitter_search.yml +0 -22
  175. data/test/integration/test_weather.yml +0 -19
  176. data/test/integration/test_xml2fluentd.yml +0 -21
  177. data/vendor/.gitkeep +0 -0
@@ -0,0 +1,542 @@
1
+ # AI Tutorial
2
+
3
+ This tutorial builds one Recipe that reads a set of public index pages, collects
4
+ the articles they list, turns them into a single text, asks an AI service one
5
+ question about that text, and writes the answer out as Markdown. It is built up
6
+ one plugin at a time, and every stage in between is a Recipe that runs and
7
+ produces something a person can read.
8
+
9
+ It assumes [`QUICKSTART.md`](QUICKSTART.md) has been followed once, so that
10
+ `automatic` runs, `~/.automatic` exists, and the habit of asking "which plugins
11
+ does this Recipe name, and what do they need" is already in place. What each
12
+ plugin does in full is [`PLUGINS.md`](PLUGINS.md) section 6; this document is
13
+ the order they go in and why.
14
+
15
+ One claim is worth stating before the first Recipe, because it is what the
16
+ tutorial is for:
17
+
18
+ **An AI filter is one filter in a pipeline.** It has no privileged position, it
19
+ is not the point of the Recipe, and it is not what makes the Recipe useful. It
20
+ takes a text, returns a text, and sits between two plugins that neither know nor
21
+ care that it is there. Everything before it is worth running on its own, and the
22
+ whole design follows from that.
23
+
24
+ ## 1. The finished shape
25
+
26
+ This is where the tutorial ends:
27
+
28
+ ```text
29
+ CustomFeedWeb find the articles the index pages list
30
+ ↓
31
+ StorePermalink drop the links already seen
32
+ ↓
33
+ FilterFullFeed replace each summary with the article body
34
+ ↓
35
+ FilterSanitize reduce the body to plain text
36
+ ↓
37
+ FilterJoin make one text out of every article
38
+ ↓
39
+ FilterSakuraAI ask one question about that text
40
+ ↓
41
+ PublishMarkdown write the answer to a document
42
+ ```
43
+
44
+ Each plugin does one thing and hands its result to the next. The
45
+ Recipe that expresses it is in section 4, and nothing in it is a special case:
46
+ every entry is a plugin the framework loads by name, and the order of the list
47
+ is the order of the work.
48
+
49
+ The store plugin's position is not an aesthetic choice. `FilterJoin` produces
50
+ one item with **no link** — it is several articles at once, so there is no page
51
+ it points at. `StorePermalink` and `StoreFullText` use the shared link-based
52
+ store path and drop an item without a link, so both belong **before**
53
+ `FilterJoin`. `StoreDigest` is different: it identifies content from the
54
+ configured fields and can store an item whose link is nil. In this tutorial it
55
+ still belongs before `FilterJoin`, because the job is to record each source
56
+ article before they are joined and before later work is repeated. Putting
57
+ `StoreDigest` after `FilterJoin` changes its meaning to de-duplicating the whole
58
+ joined digest rather than the individual articles.
59
+
60
+ ## 2. Build it without AI first
61
+
62
+ Write this to `~/.automatic/config/ai-digest.yml`. It is the finished Recipe
63
+ without `FilterJoin` and without the AI filter:
64
+
65
+ ```yaml
66
+ plugins:
67
+ - module: CustomFeedWeb
68
+ config:
69
+ retry: 2
70
+ interval: 2
71
+ sites:
72
+ - name: Python Insider
73
+ url: https://blog.python.org/
74
+ link_selector: 'a[href]'
75
+ include:
76
+ - '^https://blog\.python\.org/20[0-9]{2}/[0-9]{2}/[^/]+/?$'
77
+ fetch_items: 20
78
+
79
+ - name: Rust Blog
80
+ url: https://blog.rust-lang.org/
81
+ link_selector: 'a[href]'
82
+ include:
83
+ - '^https://blog\.rust-lang\.org/20[0-9]{2}/[0-9]{2}/[0-9]{2}/[^/]+/?$'
84
+ fetch_items: 20
85
+
86
+ - name: The Go Blog
87
+ url: https://go.dev/blog/
88
+ link_selector: 'a[href]'
89
+ include:
90
+ - '^https://go\.dev/blog/[^/]+$'
91
+ fetch_items: 20
92
+
93
+ - module: StorePermalink
94
+ config:
95
+ db: ai-digest.db
96
+
97
+ - module: FilterFullFeed
98
+ config:
99
+ siteinfo: items_all.json
100
+
101
+ - module: FilterSanitize
102
+ config:
103
+ mode: restricted
104
+
105
+ - module: PublishMarkdown
106
+ config:
107
+ file: ~/.automatic/markdown/ai-digest.md
108
+ mode: append
109
+ ```
110
+
111
+ What each plugin is responsible for, and nothing more than that:
112
+
113
+ - **`CustomFeedWeb`** makes one HTTP request per site, reads the index page as
114
+ HTML, and builds a feed from the article links it lists. `include` is what
115
+ tells an article from a navigation link. It does not follow those links and
116
+ does not read an article body.
117
+ - **`StorePermalink`** records each item's link in SQLite and passes on only the
118
+ links it had not recorded already. It is what makes the Recipe safe to run
119
+ from `cron`, and — put here, ahead of everything that costs anything — it is
120
+ also what keeps the later plugins from working on an article twice. `db` is a
121
+ file name under `~/.automatic/db`.
122
+ - **`FilterFullFeed`** takes the link of each surviving item, finds a siteinfo
123
+ record whose URL pattern matches it, fetches the page, and replaces the
124
+ summary with the body that record's XPath selects.
125
+ - **`FilterSanitize`** strips the markup out of the description, leaving text.
126
+ - **`PublishMarkdown`** writes one level-2 heading per item, with its metadata
127
+ and its body, to a document.
128
+
129
+ The Recipe's dependencies are the union of its plugins', as always:
130
+ `CustomFeedWeb` and `FilterFullFeed` need `nokogiri`, `StorePermalink` needs
131
+ `activerecord` and `sqlite3`, `FilterSanitize` needs `sanitize`, and
132
+ `PublishMarkdown` needs nothing of its own.
133
+
134
+ ```sh
135
+ gem install nokogiri
136
+ gem install activerecord sqlite3
137
+ gem install sanitize
138
+ ```
139
+
140
+ In a checkout, select the groups required by those plugins together:
141
+
142
+ ```sh
143
+ bundle config set --local with "html store sanitize"
144
+ bundle install
145
+ ```
146
+
147
+ Run it, then read what it wrote:
148
+
149
+ ```sh
150
+ automatic -c ~/.automatic/config/ai-digest.yml
151
+ sed -n '1,60p' ~/.automatic/markdown/ai-digest.md
152
+ ```
153
+
154
+ **What comes out is already useful**, and that is the first point of the
155
+ exercise. The document holds one section per new article, with its title, its
156
+ link, its date and whatever text the pipeline could get. No AI service has been
157
+ contacted, no credential exists yet, and the Recipe is complete in itself: a
158
+ person who only ever wanted this can stop here and put it in `cron`.
159
+
160
+ **Read the `FilterFullFeed` log rather than assuming bundled siteinfo
161
+ coverage.** The shipped siteinfo file is a snapshot. If a record matches an
162
+ article URL, `FilterFullFeed` fetches the page and applies that record's
163
+ XPath. If no record matches, the existing item is preserved and the log says:
164
+
165
+ ```text
166
+ Fulltext SITEINFO not found: https://example.com/article
167
+ ```
168
+
169
+ A Recipe that relies on full article bodies should supply siteinfo for the
170
+ sites it relies on under `~/.automatic/assets/siteinfo/`, rather than treating
171
+ the bundled snapshot as a guarantee of coverage.
172
+
173
+ Where no record matches, the plugin leaves the item exactly as it arrived and
174
+ the run continues. If the index page also provides no description, that item
175
+ therefore has metadata but no article body. If a bundled or user-supplied
176
+ siteinfo record does match, `FilterFullFeed` may replace the description with
177
+ the selected article body. The tutorial relies on that conditional behavior,
178
+ not on either outcome being guaranteed for a particular site.
179
+
180
+ Text can enter these items from the index page or from the article page,
181
+ and both are
182
+ configuration rather than code:
183
+
184
+ - **From the index page.** Where the listing prints a summary, an
185
+ `item_selector` marking one article's node with a `description_selector`
186
+ inside it puts that summary in the item. `CustomFeedWeb` requires the first to
187
+ use the second, because without a node there is nothing to read the summary
188
+ in.
189
+ - **From the article.** Supply your own siteinfo file in
190
+ `~/.automatic/assets/siteinfo/`, with the URL patterns and XPaths of the sites
191
+ you actually read, and `FilterFullFeed` fetches each page and puts its body in
192
+ the item.
193
+
194
+ The rest of this tutorial holds either way: what follows cares that each item
195
+ carries text, not where the text came from. It is worth having one of the two
196
+ working before the AI filter is added, because a joined text of titles and URLs
197
+ is what a summary of nothing looks like.
198
+
199
+ Read the log rather than only the document. It names each page fetched, each
200
+ link stored, each siteinfo lookup that missed, and the file written. Every stage
201
+ below is judged the same way.
202
+
203
+ ## 3. Make one text out of many items
204
+
205
+ Add `FilterJoin` between `FilterSanitize` and `PublishMarkdown`:
206
+
207
+ ```yaml
208
+ - module: FilterJoin
209
+ config:
210
+ title: Daily Digest
211
+ ```
212
+
213
+ Run it again and the document changes shape. Where the previous run wrote one
214
+ section per article, this one writes a single section titled `Daily Digest`,
215
+ whose body holds every article in sequence:
216
+
217
+ ```text
218
+ ARTICLE 1
219
+ Title: Enabling Polonius on nightly
220
+ URL: https://blog.rust-lang.org/2026/08/04/enabling-polonius-alpha-on-nightly/
221
+
222
+ The body of the first article.
223
+
224
+ ARTICLE 2
225
+ Title: Extending the pkgsite API
226
+ URL: https://go.dev/blog/pkgsite-api
227
+
228
+ The body of the second article.
229
+ ```
230
+
231
+ That is the whole of what `FilterJoin` does: many items in, one item out. It
232
+ fetches nothing, summarizes nothing, parses no HTML and calls no service. Its
233
+ only setting is the title of the item it produces.
234
+
235
+ It is worth being clear about why this is its own plugin rather than part of
236
+ what comes next. Joining a day's release notes, log lines or notifications into
237
+ one document is the same operation, and it is useful with no AI service
238
+ anywhere in the Recipe. The plugin adds no prompt and knows nothing about what
239
+ reads its result; what the joined text is *for* is decided entirely by the
240
+ plugin the Recipe puts after it.
241
+
242
+ Note also what the joined item lost: its link. This is the stage the ordering
243
+ rule from section 1 is about, and it is visible in the document — the joined
244
+ section carries a title and no `Link` bullet.
245
+
246
+ ## 4. Ask one question about that text
247
+
248
+ Now the AI filter goes between `FilterJoin` and `PublishMarkdown`:
249
+
250
+ ```yaml
251
+ - module: FilterSakuraAI
252
+ config:
253
+ token: YOUR_SAKURA_AI_TOKEN
254
+ model: <Sakura AI Engine model>
255
+ prompt: |
256
+ 以下の記事群について、個別記事の要約を羅列するのではなく、
257
+ 全体を一つのダイジェストとして日本語で要約してください。
258
+ retry: 2
259
+ interval: 2
260
+ ```
261
+
262
+ The pipeline holds one item, so the service is asked **once**. The `prompt` is
263
+ sent as the instruction and the item's description — the joined text from
264
+ section 3 — as the text to work on. What comes back replaces the description.
265
+ The title stays `Daily Digest`, the item count stays one, and `PublishMarkdown`
266
+ writes the answer exactly where the joined text used to be.
267
+
268
+ The whole Recipe, with the two plugins added since section 2 and the site list
269
+ shortened to one site:
270
+
271
+ ```yaml
272
+ plugins:
273
+ - module: CustomFeedWeb
274
+ config:
275
+ retry: 2
276
+ interval: 2
277
+ sites:
278
+ - name: The Go Blog
279
+ url: https://go.dev/blog/
280
+ link_selector: 'a[href]'
281
+ include:
282
+ - '^https://go\.dev/blog/[^/]+$'
283
+ fetch_items: 20
284
+
285
+ - module: StorePermalink
286
+ config:
287
+ db: ai-digest.db
288
+
289
+ - module: FilterFullFeed
290
+ config:
291
+ siteinfo: items_all.json
292
+
293
+ - module: FilterSanitize
294
+ config:
295
+ mode: restricted
296
+
297
+ - module: FilterJoin
298
+ config:
299
+ title: Daily Digest
300
+
301
+ - module: FilterSakuraAI
302
+ config:
303
+ token: YOUR_SAKURA_AI_TOKEN
304
+ model: <Sakura AI Engine model>
305
+ prompt: |
306
+ 以下の記事群について、個別記事の要約を羅列するのではなく、
307
+ 全体を一つのダイジェストとして日本語で要約してください。
308
+ retry: 2
309
+ interval: 2
310
+
311
+ - module: PublishMarkdown
312
+ config:
313
+ file: ~/.automatic/markdown/ai-digest.md
314
+ mode: append
315
+ ```
316
+
317
+ **The token is a Recipe setting, which makes the Recipe a secret file.** Put the
318
+ real token in the file on the machine that runs it, keep the placeholder in
319
+ anything you commit, and restrict the file:
320
+
321
+ ```sh
322
+ chmod 600 ~/.automatic/config/ai-digest.yml
323
+ ```
324
+
325
+ The plugin never logs the token, never puts it in an exception message and never
326
+ writes it into an item, and TLS certificates are verified — but a Recipe in a
327
+ repository or a world-readable Recipe defeats all of that. `~/.automatic/config`
328
+ is not a place to keep files under version control.
329
+
330
+ **`retry` and `interval` are about the network, not about the answer.** A
331
+ timeout, a `429` and a `5xx` are attempted again, up to `retry` times,
332
+ `interval` seconds apart. A refused request, an answer that is not JSON, an
333
+ answer whose shape is not the documented one, and a missing or wrong setting all
334
+ end the run instead, because the next attempt would fail the same way. A missing
335
+ `token`, `model` or `prompt` is refused before the first request is made.
336
+
337
+ A failure never leaves an empty description behind: the run ends rather than
338
+ publishing the article as a blank. That is why the stage in section 2 matters —
339
+ when the service is unavailable, removing one line from the Recipe returns a
340
+ pipeline that still writes a document.
341
+
342
+ ## 5. Swap the order, and the Recipe means something else
343
+
344
+ Move the AI filter to before `FilterJoin` and change nothing else:
345
+
346
+ ```yaml
347
+ - module: FilterSanitize
348
+ config:
349
+ mode: restricted
350
+
351
+ - module: FilterSakuraAI
352
+ config:
353
+ token: YOUR_SAKURA_AI_TOKEN
354
+ model: <Sakura AI Engine model>
355
+ prompt: |
356
+ 以下の記事を日本語で三行に要約してください。
357
+ retry: 2
358
+ interval: 2
359
+
360
+ - module: FilterJoin
361
+ config:
362
+ title: Daily Digest
363
+ ```
364
+
365
+ Every AI filter makes **one request per item**, so with three articles in the
366
+ pipeline the two arrangements differ:
367
+
368
+ | Order | Requests, for three articles | What the answer is |
369
+ | --- | --- | --- |
370
+ | `FilterJoin` → AI filter | 1 | One text about everything at once |
371
+ | AI filter → `FilterJoin` | 3 | Three separate texts, joined afterwards |
372
+
373
+ Both are legitimate, and they answer different questions. A digest that draws a
374
+ theme across the day's articles needs the first; a document of per-article
375
+ summaries needs the second. The second is also the one that grows with the feed:
376
+ a run that finds forty new articles makes forty requests, each of them billed
377
+ and rate-limited. `StorePermalink` earlier in the Recipe is the main defence,
378
+ because it means each article is sent once ever; `FilterOne` ahead of the filter
379
+ is the blunt one, while a Recipe is being written.
380
+
381
+ The point is not which order is better. It is that the difference between "one
382
+ digest" and "a list of summaries" is the position of one entry in a YAML list,
383
+ and that no plugin had to be changed or configured to express it.
384
+
385
+ ## 6. Change the service by changing one line
386
+
387
+ Automatic Ruby uses separate service-specific AI filters rather than one
388
+ plugin with a `provider` setting. Their current catalogue and settings are
389
+ maintained in [`PLUGINS.md`](PLUGINS.md) section 6.3. Each replaces an item's description with what its service answers, so
390
+ in a Recipe they are interchangeable at the same position:
391
+
392
+ ```yaml
393
+ - module: FilterOpenAI
394
+ config:
395
+ token: YOUR_OPENAI_API_KEY
396
+ model: <OpenAI model>
397
+ prompt: |
398
+ 以下の記事群を一つのダイジェストとして日本語で要約してください。
399
+ retry: 2
400
+ interval: 2
401
+ ```
402
+
403
+ ```yaml
404
+ - module: FilterClaude
405
+ config:
406
+ token: YOUR_ANTHROPIC_API_KEY
407
+ model: <Anthropic model>
408
+ prompt: |
409
+ 以下の記事群を一つのダイジェストとして日本語で要約してください。
410
+ max_tokens: 2048
411
+ retry: 2
412
+ interval: 2
413
+ ```
414
+
415
+ ```yaml
416
+ - module: FilterGemini
417
+ config:
418
+ token: YOUR_GEMINI_API_KEY
419
+ model: <Gemini model>
420
+ prompt: |
421
+ 以下の記事群を一つのダイジェストとして日本語で要約してください。
422
+ retry: 2
423
+ interval: 2
424
+ ```
425
+
426
+ ```yaml
427
+ - module: FilterKimi
428
+ config:
429
+ token: YOUR_KIMI_API_KEY
430
+ model: kimi-k3
431
+ prompt: |
432
+ 以下の記事群を一つのダイジェストとして日本語で要約してください。
433
+ retry: 2
434
+ interval: 2
435
+ ```
436
+
437
+ Nothing before or after the swapped entry changes. `token`, `model` and
438
+ `prompt` are shared required settings of the AI filters; `max_tokens` exists
439
+ only for Claude because that API requires it. Model names move with the services, so take them from the provider
440
+ you are using rather than from this document, and see
441
+ [`PLUGINS.md`](PLUGINS.md) section 6.3 for each plugin's endpoint,
442
+ authentication and answer handling.
443
+
444
+ A Recipe naming the service on its face is the reason for keeping
445
+ service-specific plugins. A
446
+ line reading `FilterClaude` says where the text is going, which is a question
447
+ worth being able to answer by reading the Recipe.
448
+
449
+ ## 7. Change the prompt, and the Recipe does another job
450
+
451
+ None of the AI filters is a summarizer. The prompt is the instruction and the
452
+ item's description is the text it applies to, so summarizing, translating,
453
+ extracting and classifying are the same plugin with different words in one
454
+ setting. Keeping the Recipe of section 4 and replacing only the `prompt`:
455
+
456
+ ```yaml
457
+ prompt: |
458
+ 以下の記事群から、セキュリティに関係する記述だけを抜き出し、
459
+ 日本語の箇条書きにしてください。該当がなければ「該当なし」と答えてください。
460
+ ```
461
+
462
+ ```yaml
463
+ prompt: |
464
+ Translate the following articles into English, keeping each
465
+ article's heading and order.
466
+ ```
467
+
468
+ ```yaml
469
+ prompt: |
470
+ 以下の記事群を、テーマごとに見出しを付けて分類してください。
471
+ 本文の引用はせず、見出しと一行の説明だけを出力してください。
472
+ ```
473
+
474
+ There is no default prompt: a Recipe without one is refused rather than being
475
+ given a purpose it did not ask for. The instruction and the article text are
476
+ sent as separate fields — a system instruction and a user turn — so that what an
477
+ article says is text to be worked on rather than an instruction to obey. It is
478
+ still worth remembering what the input is: pages fetched from the open web. A
479
+ prompt that states what to do when the text does not contain what was asked for
480
+ is more robust than one that assumes it does.
481
+
482
+ **Long input has a limit that belongs to the model, not to the framework.**
483
+ A joined text can exceed what a model accepts, and the service then returns an
484
+ error that ends the run. `FilterLimit` can cap how many source items proceed,
485
+ and `FilterBatch` can group source items into fixed-size item batches before
486
+ an AI filter so that one request need not contain the whole pipeline. Their
487
+ exact behavior is specified in [`PLUGINS.md`](PLUGINS.md) section 6.3.
488
+
489
+ `FilterBatch` does not split one already-joined text by character or token
490
+ length, and Automatic Ruby does not perform recursive summarization or a
491
+ final synthesis automatically. A Recipe that needs those operations still
492
+ needs a different pipeline design or a purpose-built component.
493
+
494
+ ## 8. Why it is built this way
495
+
496
+ Read the finished Recipe again as a shell pipeline and the design is not novel:
497
+
498
+ ```text
499
+ discover | deduplicate | fetch | clean | join | transform | write
500
+ ```
501
+
502
+ Each plugin takes the pipeline, does one thing to it, and hands it on. The
503
+ interface between two plugins is items carrying text, which is why the AI filter
504
+ needed no cooperation from `FilterJoin` and why `PublishMarkdown` needed none
505
+ from either.
506
+
507
+ What that buys, concretely:
508
+
509
+ - **Every prefix of the Recipe is a working Recipe.** Section 2 publishes
510
+ articles, section 3 publishes a joined document, section 4 publishes an
511
+ answer. When the last stage fails, the earlier ones still say what the run
512
+ found.
513
+ - **A failure has one address.** Nothing was fetched is `CustomFeedWeb`;
514
+ everything is a summary is `FilterFullFeed` and its siteinfo; the document is
515
+ one section is `FilterJoin`; the request was refused is the AI filter. A
516
+ single plugin that did all of it would have one log and one place to guess in.
517
+ - **Changing behaviour is editing configuration, not code.** A different
518
+ service, a different job, one digest instead of many summaries: three edits to
519
+ one YAML file, each of them one line or one setting.
520
+ - **The parts recombine.** `FilterJoin` before a Markdown file, an AI filter
521
+ over a feed that needs no joining, a store plugin with neither: none of these
522
+ is a special case of the others.
523
+
524
+ The plugin this repository deliberately does not have is the one that would be
525
+ easiest to want: a `FilterDigest` that fetches the articles, joins them,
526
+ summarizes them and writes the file. It would be quicker to configure once, and
527
+ it would be the only way to do any of it. Wanting the joined text without the
528
+ summary, or the summary of one feed rather than of a day, or the same articles
529
+ sent to a different service, would each be a new setting on it, and its log
530
+ would tell you only that "the digest failed".
531
+
532
+ The AI service is the newest thing in the Recipe and the least special: a filter
533
+ that takes a text and returns a text, in a list of filters that do the same.
534
+
535
+ ## Next
536
+
537
+ Every setting used above, and every plugin that could take a place in this
538
+ pipeline, is in [`PLUGINS.md`](PLUGINS.md) section 6 — the Recipe format in
539
+ section 2, the AI filters in section 6.3. Installing the optional gems,
540
+ scheduling a Recipe and reading its log when it fails is
541
+ [`DEPLOYMENT.md`](DEPLOYMENT.md). A filter of your own, in the same shape as the
542
+ ones used here, is [`PLUGIN_DEVELOPMENT.md`](PLUGIN_DEVELOPMENT.md).