automatic 14.12.2 → 26.08

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