@llm-newsletter-kit/core 3.0.3 → 3.0.5

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.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Important: [Code of Conduct](./CODE_OF_CONDUCT.md) • [Security Policy](./SECUR
16
16
 
17
17
  ## What is this?
18
18
 
19
- A type‑first, extensible toolkit that automates LLM‑based newsletter creation end‑to‑end. It orchestrates Crawling → Analysis → Content Generation → Save (with optional preview email), and every stage is swappable via DI‑capable Provider interfaces so you can plug in your own crawlers/LLMs/DB/logging. Built‑in operational features (retries, chain options) help control cost and improve reliability.
19
+ A type‑first, extensible toolkit that automates LLM‑based newsletter creation end‑to‑end. It orchestrates Crawling → Analysis → Content Generation → Save (with optional preview email), and every stage is swappable via DI‑capable Provider interfaces so you can plug in your own crawlers/LLMs/DB/logging. Built-in operational features include retries, structured logging, partial crawling failure handling, and preview delivery. Your application supplies persistence, scheduling, and subscriber delivery.
20
20
 
21
21
  - Type-first design (TypeScript, ESM) with strong contracts
22
22
  - Flexible dependency injection: easily swap Crawling/Analysis/ContentGenerate/Task/Logging/Email
@@ -51,12 +51,12 @@ His design philosophy: **"Logic in code, reasoning in AI, connections in archite
51
51
 
52
52
  ## Why Code-Based?
53
53
 
54
- Newsletter automation generally falls into two approaches: no-code and code-based. **This kit takes the code-based approach because it produces significantly better output quality.**
54
+ Newsletter automation generally falls into two approaches: no-code and code-based. **This kit takes the code-based approach to make domain logic, model choices, and persistence explicit and customizable.**
55
55
 
56
56
  **Key advantages:**
57
57
 
58
- - **Advanced AI workflows**: Implement sophisticated techniques like self-reflection, chain-of-thought reasoning, and multi-step verification—impossible or prohibitively expensive in no-code platforms
59
- - **Cost control**: Use different models per stage, cap tokens, control retries, and prevent runaway costs with granular configuration
58
+ - **Domain-specific workflows**: Combine tag classification, image context, importance scoring, and newsletter generation with per-stage model and prompt configuration
59
+ - **Model and output controls**: Use different models per stage, set the newsletter output token limit, and configure SDK and chain retries
60
60
  - **Full customization**: Swap any component (crawlers, LLMs, databases, email) via Provider interfaces without vendor lock-in
61
61
  - **Production-grade**: Type-safe contracts, 100% test coverage, CI/CD integration, and observability built-in
62
62
 
@@ -71,73 +71,236 @@ See the quality for yourself—an actual newsletter generated by this kit: https
71
71
  npm i @llm-newsletter-kit/core
72
72
  ```
73
73
 
74
- - Node.js >= 22 (CI verified with 24.x)
74
+ - Node.js **>= 24** (CI uses 24.x)
75
+ - ESM-only distribution: `dist/index.js` and `dist/index.d.ts`; use named imports.
76
+ - The core uses AI SDK 7 and Zod 4. Install an AI SDK-compatible model provider separately; provider adapters are development dependencies in this repository, not runtime dependencies of the published package.
75
77
 
76
78
  ## Quick Start
77
79
 
80
+ Install the OpenAI adapter for this example:
81
+
82
+ ```bash
83
+ npm i @ai-sdk/openai@^4
84
+ ```
85
+
86
+ Set `OPENAI_API_KEY` and `OPENAI_MODEL` to your API key and an available model ID that supports structured output. Save the following as `example.ts` in an ESM TypeScript project and run it with your TypeScript runner (for example, `npx tsx example.ts`). This example uses one fictional, preloaded article and in-memory storage; crawling is empty so you can try analysis and generation before connecting a scraper or database. It makes real LLM requests.
87
+
78
88
  ```ts
79
- import type { GenerateNewsletterConfig } from '@llm-newsletter-kit/core';
89
+ import type {
90
+ ArticleForGenerateContent,
91
+ GenerateNewsletterConfig,
92
+ Newsletter,
93
+ } from '@llm-newsletter-kit/core';
80
94
 
81
95
  import { createOpenAI } from '@ai-sdk/openai';
82
96
  import { GenerateNewsletter } from '@llm-newsletter-kit/core';
83
97
 
84
- const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
98
+ const apiKey = process.env.OPENAI_API_KEY;
99
+ const modelId = process.env.OPENAI_MODEL;
100
+ if (!apiKey || !modelId) {
101
+ throw new Error('Set OPENAI_API_KEY and OPENAI_MODEL');
102
+ }
103
+ const model = createOpenAI({ apiKey })(modelId);
104
+ const publicationDate = '2026-09-09';
105
+ const articles = new Map<string | number, ArticleForGenerateContent>([
106
+ [
107
+ 'demo-1',
108
+ {
109
+ id: 'demo-1',
110
+ title: 'Example Lab releases an open research dataset',
111
+ detailContent:
112
+ 'Example Lab published an open dataset of 1,000 annotated documents ' +
113
+ 'on September 9, 2026 for evaluating document classification.',
114
+ hasAttachedImage: false,
115
+ imageContextByLlm: null,
116
+ tag1: null,
117
+ tag2: null,
118
+ tag3: null,
119
+ targetUrl: 'https://example.com/news',
120
+ publishedDate: publicationDate,
121
+ importanceScore: 0, // Local sentinel for an article awaiting analysis
122
+ contentType: 'Research',
123
+ url: 'https://example.com/news/dataset',
124
+ },
125
+ ],
126
+ ]);
127
+ const newsletters: Newsletter[] = [];
85
128
 
86
129
  const config: GenerateNewsletterConfig<string> = {
87
130
  contentOptions: {
88
131
  outputLanguage: 'English',
89
132
  expertField: ['Technology', 'AI'],
90
- // freeFormIntro: true, // Optional: free-form briefing intro
91
- // titleContext: 'AI Weekly', // Optional: keyword to include in title
133
+ freeFormIntro: true,
134
+ // titleContext: 'AI Weekly', // Required substring in the generated title
92
135
  },
93
136
  dateService: {
94
- getPublicationISODateString: () => new Date().toISOString().split('T')[0],
95
- getPublicationDisplayDateString: () =>
96
- new Date().toLocaleDateString('en-US'),
137
+ getPublicationISODateString: () => publicationDate,
138
+ getPublicationDisplayDateString: () => 'September 9, 2026',
97
139
  },
98
140
  taskService: {
99
141
  start: async () => `task-${Date.now()}`,
100
142
  end: async () => {},
101
143
  },
102
144
  crawlingProvider: {
103
- // customFetch: myProxyFetch, // Optional: custom fetch for proxy support
104
- crawlingTargetGroups: [/* ... */],
105
- fetchExistingArticlesByUrls: async (urls) => [/* ... */],
106
- saveCrawledArticles: async (articles, context) => articles.length,
145
+ crawlingTargetGroups: [],
146
+ fetchExistingArticlesByUrls: async () => [],
147
+ saveCrawledArticles: async () => 0,
107
148
  },
108
149
  analysisProvider: {
109
- // Configure LLM models for analysis
110
- classifyTagOptions: { model: openai('gpt-5.4-nano') },
111
- analyzeImagesOptions: { model: openai('gpt-5.4-nano') },
112
- determineScoreOptions: { model: openai('gpt-5.4-nano') },
113
- fetchUnscoredArticles: async () => [/* ... */],
114
- fetchTags: async () => [/* ... */],
115
- update: async (article) => {},
150
+ classifyTagOptions: { model },
151
+ analyzeImagesOptions: { model }, // Use a multimodal model for real images
152
+ determineScoreOptions: { model },
153
+ fetchUnscoredArticles: async () =>
154
+ [...articles.values()].filter((article) => article.importanceScore === 0),
155
+ fetchTags: async () => ['Research', 'Datasets', 'Document Classification'],
156
+ update: async (article) => {
157
+ const existing = articles.get(article.id);
158
+ if (existing) articles.set(article.id, { ...existing, ...article });
159
+ },
116
160
  },
117
161
  contentGenerateProvider: {
118
- // Configure content generation
119
- model: openai('gpt-5.4-nano'),
162
+ model,
120
163
  issueOrder: 1,
121
164
  newsletterBrandName: 'Tech Insight Weekly',
122
- publicationCriteria: { minimumArticleCountForIssue: 5 },
123
- fetchArticleCandidates: async () => [/* ... */],
124
- htmlTemplate: ({ content }) => `<html>...</html>`,
125
- saveNewsletter: async ({ newsletter }) => ({ id: 1 }),
165
+ publicationCriteria: {
166
+ minimumArticleCountForIssue: 0, // Allow this single-article demo
167
+ priorityArticleScoreThreshold: 8,
168
+ },
169
+ fetchArticleCandidates: async () => [...articles.values()],
170
+ htmlTemplate: {
171
+ html: '<html><body><h1>{{title}}</h1>{{content}}</body></html>',
172
+ },
173
+ saveNewsletter: async ({ newsletter }) => {
174
+ newsletters.push(newsletter);
175
+ return { id: newsletters.length };
176
+ },
177
+ },
178
+ options: {
179
+ logger: {
180
+ info: (message) => console.info(message),
181
+ debug: (message) => console.debug(message),
182
+ error: (message) => console.error(message),
183
+ },
126
184
  },
127
185
  };
128
186
 
129
- const generator = new GenerateNewsletter(config);
130
- const newsletterId = await generator.generate();
187
+ const newsletterId = await new GenerateNewsletter(config).generate();
188
+ console.log({ newsletterId, newsletter: newsletters.at(-1) });
131
189
  ```
132
190
 
133
- **⚠️ This is a minimal example showing the structure. For a complete, production-ready implementation with:**
191
+ For database-backed providers, real crawling targets, email templates, and delivery integration, see the [Research Radar reference implementation](https://github.com/heripo-lab/heripo-research-radar).
192
+
193
+ ## Configuration & Provider Contracts
194
+
195
+ The complete configuration type is [GenerateNewsletterConfig](./src/generate-newsletter/models/interfaces.ts). All three providers, `contentOptions`, `dateService`, and `taskService` are required; `options` and `promptProvider` are optional.
196
+
197
+ | Configuration | Responsibility |
198
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
199
+ | `contentOptions` | `outputLanguage`, `expertField` (string or array), optional `freeFormIntro` and `titleContext` |
200
+ | `dateService` | Supply the **newsletter publication date**, including its localized display string; this can be a future scheduled date |
201
+ | `taskService` | Start/end the task; implement locking or duplicate-run prevention in your application |
202
+ | `crawlingProvider` | Supply grouped targets, parsing functions, existing-URL lookup, and persistence |
203
+ | `analysisProvider` | Supply three models, unscored articles, existing tags, and an update function |
204
+ | `contentGenerateProvider` | Supply a model, candidates, issue number, brand, HTML template, and newsletter persistence |
205
+ | `options` | Logging, SDK retry count, chain attempt count, and preview email |
206
+ | `promptProvider` | Replace system/user prompts independently for each LLM stage |
207
+
208
+ ### Data passed between providers
209
+
210
+ Crawling results are persisted before analysis reads unscored articles. Analysis updates are persisted before generation reads candidates. The core does not maintain an article database or automatically pass crawled records into the next provider: your provider queries connect the stages.
211
+
212
+ - `ParsedTargetListItem`: optional `uniqId`, `title`, `date` (`YYYY-MM-DD`), `dateType` (`DateType.REGISTERED` or `DateType.DURATION`), and `detailUrl`.
213
+ - `ParsedTargetDetail`: `detailContent` in Markdown, `hasAttachedFile`, and `hasAttachedImage`. A `ParsedTarget` combines both.
214
+ - `UnscoredArticle`: `id`, `title`, `detailContent`, `hasAttachedImage`, nullable `imageContextByLlm` and `tag1`/`tag2`/`tag3`, `targetUrl`, and optional `publishedDate`.
215
+ - `ArticleForUpdateByAnalysis`: the unscored article plus `importanceScore` (1–10).
216
+ - `ArticleForGenerateContent`: the analyzed article plus `contentType` and original detail-page `url`.
217
+ - `Newsletter`: `title`, Markdown `content`, rendered `htmlBody`, `issueOrder`, and publication `date`.
218
+
219
+ `targetUrl` identifies the source/list page; `url` (or `detailUrl` during crawling) identifies an individual article. Map the parsed `date` to `publishedDate` when it represents the original posting date. Your application chooses eligible candidates, including date ranges, already-used articles, and score filters.
220
+
221
+ `saveNewsletter({ newsletter, usedArticles })` receives **all fetched candidates**, including any the LLM omits from its text. It is not a list of verified citations. Return `{ id: string | number }` after persistence, and handle article relationships in your storage layer.
222
+
223
+ ### Publication criteria
224
+
225
+ If `publicationCriteria` is supplied, both fields are required by the TypeScript interface. Defaults are `minimumArticleCountForIssue: 5` and `priorityArticleScoreThreshold: 8`.
226
+
227
+ The current implementation generates only when there is at least one candidate and either:
228
+
229
+ - candidate count is **greater than** `minimumArticleCountForIssue`; or
230
+ - at least one candidate has `importanceScore >= priorityArticleScoreThreshold`.
231
+
232
+ Thus, five ordinary candidates are skipped with the defaults; six qualify. A single article scoring 8 also qualifies. With no candidates, generation always returns `null`. The count comparison is strict despite the option's name.
233
+
234
+ ### Content and model options
235
+
236
+ - `freeFormIntro: true` changes the default prompt to begin with an H2 briefing section instead of a separate opening H1/greeting.
237
+ - `titleContext` is a required, case-sensitive substring checked against the generated title. The title schema allows 20–70 characters, so choose a phrase that fits.
238
+ - `subscribePageUrl` adds a subscription CTA instruction to the default generation prompt.
239
+ - `temperature` defaults to `0.3`. `maxOutputTokens`, `topP`, `topK`, `presencePenalty`, and `frequencyPenalty` are forwarded when supplied; otherwise they are unset. In particular, the runtime does not set a `topP` default.
240
+ - A generation response with `finishReason: 'length'` throws instead of saving truncated content. Increase `maxOutputTokens` or reduce input articles.
241
+ - Analysis uses structured outputs for three tags, image context, and a 1–10 importance score. Models must support the required AI SDK operations; image analysis additionally requires multimodal input.
242
+
243
+ The default scoring and generation prompts compare deadlines/events against the injected publication date. `publishedDate` provides additional freshness context. `minimumImportanceScoreRules` matches a source's `targetUrl` and changes scoring instructions; it does not clamp the returned score in code. Expiration and source-grounding instructions are prompt behavior, not deterministic filtering or independent fact verification.
244
+
245
+ ### HTML templates
246
+
247
+ `htmlTemplate` is an object containing `html` and optional `markers`, not a callback. The default placeholders are **`{{title}}` and `{{content}}`**, and all occurrences are replaced in the core pipeline. For custom placeholders:
248
+
249
+ ```ts
250
+ const htmlTemplate = {
251
+ html: '<html><body><h1>{{NEWSLETTER_TITLE}}</h1>{{NEWSLETTER_CONTENT}}</body></html>',
252
+ markers: { title: 'NEWSLETTER_TITLE', content: 'NEWSLETTER_CONTENT' },
253
+ };
254
+ ```
255
+
256
+ The rendering pipeline adds horizontal rules before H2 sections where needed, converts Markdown with `safe-markdown2html` using a JSDOM window (link targets, malformed URL/bold repairs, and strikethrough conversion enabled), inserts the result into the template, then inlines CSS with `juice`. The saved Markdown remains the generated Markdown; `htmlBody` contains the final email HTML.
257
+
258
+ ### Retries, logging, and task lifecycle
259
+
260
+ | Setting | Default | Scope |
261
+ | --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
262
+ | `options.llm.maxRetries` | `5` | Passed to the AI SDK for each LLM request |
263
+ | `options.chain.stopAfterAttempt` | `3` | LangChain attempts for each crawling target pipeline, the analysis chain, and the content generation chain |
264
+ | `crawlingProvider.maxConcurrency` | `5` | Concurrent target pipelines within each group |
265
+ | `options.logger` | No-op | Inject an `AppLogger` with `info`, `debug`, and `error` |
266
+
267
+ Chain retries can repeat provider reads, LLM calls, and writes; make persistence idempotent. These settings are not a total request or cost cap: incomplete analysis can run up to five additional insight passes, and newsletter generation recursively retries when model-reported language/copyright/factual checks fail or `titleContext` is missing. Those content-validation retries currently have no separate attempt limit.
268
+
269
+ `generate()` calls `taskService.start()`, runs the pipeline, and calls `taskService.end()` in `finally` after a successful start. Preview delivery runs **after task cleanup**. The method resolves to the saved ID or `null` when skipped; unhandled pipeline or task-service errors reject. Scheduling, cross-process locking, issue-number increments, and subscriber distribution belong to your application.
134
270
 
135
- - Real database integration (Prisma/Drizzle)
136
- - Actual crawling targets and parsing logic
137
- - HTML email templates
138
- - Preview email configuration
271
+ Structured operation logs include `.start`, `.done`, and `.error` events, task IDs, timing, and context. Result events are `generate.result.created` and `generate.result.skipped`.
139
272
 
140
- 👉 **See the reference implementation: https://github.com/heripo-lab/heripo-research-radar**
273
+ ### Preview email
274
+
275
+ For the Quick Start example, add preview configuration before calling `generate()`. Supply your email adapter's `send` implementation:
276
+
277
+ ```ts
278
+ import type { EmailService } from '@llm-newsletter-kit/core';
279
+
280
+ function configurePreview(emailService: EmailService) {
281
+ config.options = {
282
+ ...config.options,
283
+ previewNewsletter: {
284
+ fetchNewsletterForPreview: async () => {
285
+ const newsletter = newsletters.at(-1);
286
+ if (!newsletter) throw new Error('No newsletter saved');
287
+ return newsletter;
288
+ },
289
+ emailService,
290
+ emailMessage: {
291
+ from: 'newsletter@example.com',
292
+ to: ['reviewer@example.com'],
293
+ },
294
+ },
295
+ };
296
+ }
297
+
298
+ // configurePreview(yourEmailService);
299
+ ```
300
+
301
+ `previewNewsletter` requires `fetchNewsletterForPreview`, `emailService` (`send(message)`), and `emailMessage` with `from`, `to`, and optional CC/BCC, reply-to, headers, or attachments. The fetch callback receives no ID, so bind it to the newsletter saved by the current run.
302
+
303
+ The core sets the subject to `[Preview] <title>`, uses `htmlBody` for HTML, and builds a short plain-text issue summary. It skips preview when the result is `null`; fetch/send failures are logged without changing the saved ID returned by `generate()`. This option sends review copies, not a subscriber campaign.
141
304
 
142
305
  ## Customizing LLM Prompts (PromptProvider)
143
306
 
@@ -166,15 +329,12 @@ PromptProvider
166
329
 
167
330
  Each prompt slot is a `PromptBuilder<TContext>` — an object with optional `system` and `user` functions. The function receives a typed context object containing all the data the default prompt would use (articles, tags, expert fields, dates, etc.), so you have full control over prompt construction.
168
331
 
169
- > **Dependency note:** Analysis prompts (classifyTags → analyzeImages → determineImportance) produce data that flows into the content generation prompt. Changing an analysis prompt may indirectly affect the final newsletter output.
332
+ > **Dependency note:** Tag classification and image analysis run in parallel, then feed importance scoring. These stages produce data that flows into the content generation prompt. Changing an analysis prompt may indirectly affect the final newsletter output.
170
333
 
171
334
  ### Example
172
335
 
173
336
  ```ts
174
- import type {
175
- GenerateNewsletterConfig,
176
- PromptProvider,
177
- } from '@llm-newsletter-kit/core';
337
+ import type { PromptProvider } from '@llm-newsletter-kit/core';
178
338
 
179
339
  const promptProvider: PromptProvider = {
180
340
  analysis: {
@@ -213,43 +373,58 @@ const promptProvider: PromptProvider = {
213
373
  },
214
374
  };
215
375
 
216
- const config: GenerateNewsletterConfig<string> = {
217
- // ... other config (contentOptions, dateService, taskService, providers, etc.)
218
- promptProvider, // Inject custom prompts
219
- };
376
+ // Add to the config from Quick Start before creating the generator:
377
+ config.promptProvider = promptProvider;
220
378
  ```
221
379
 
222
- For full context types (`ClassifyTagsPromptContext`, `GenerateNewsletterPromptContext`, etc.), see `src/generate-newsletter/models/prompt-provider.ts`.
380
+ Prompt builders return strings synchronously. They replace prompts without changing output schemas, image attachments, title checks, or retry behavior. When replacing a prompt, include any date/source/format rules you want to retain.
223
381
 
224
- ## Public API Overview
382
+ For full exported context types (`ClassifyTagsPromptContext`, `AnalyzeImagesPromptContext`, `DetermineImportancePromptContext`, and `GenerateNewsletterPromptContext`), see [prompt-provider.ts](./src/generate-newsletter/models/prompt-provider.ts).
225
383
 
226
- Entry point: `src/index.ts`
384
+ ## Public API Overview
227
385
 
228
- - Class
229
- - GenerateNewsletter<TaskId>
230
- - constructor(config: GenerateNewsletterConfig<TaskId>)
231
- - generate(): Promise<string | number | null>
232
- - Main Types
233
- - TaskService<TaskId> { start(): Promise<TaskId>; end(): Promise<void> }
234
- - CrawlingProvider { crawlingTargetGroups, customFetch?, fetchExistingArticlesByUrls, saveCrawledArticles, ... }
235
- - AnalysisProvider { classifyTagOptions.model, analyzeImagesOptions.model, determineScoreOptions(model, minimumImportanceScoreRules), fetchUnscoredArticles, fetchTags, update }
236
- - ContentGenerateProvider { model and generation options, issueOrder, publicationCriteria, subscribePageUrl, newsletterBrandName, fetchArticleCandidates, htmlTemplate, saveNewsletter }
237
- - GenerateNewsletterOptions { logger, llm, chain, previewNewsletter(emailService/emailMessage/fetchNewsletterForPreview) }
238
- - PromptProvider { analysis?(classifyTags, analyzeImages, determineImportance), contentGenerate?(generateNewsletter) }
239
- - Domain models: DateService, EmailService, Newsletter, etc.
386
+ The package root exports the named `GenerateNewsletter` class and the `DateType` enum, plus provider/configuration, article, crawling, template, prompt, newsletter, date, email, logging, and common types. See the full export list in [src/index.ts](./src/index.ts).
240
387
 
241
- For detailed field descriptions, see `src/generate-newsletter/models/interfaces.ts` and type definitions under `src/models/*`.
388
+ ```ts
389
+ import type {
390
+ AnalysisProvider,
391
+ ContentGenerateProvider,
392
+ CrawlingProvider,
393
+ GenerateNewsletterConfig,
394
+ PromptProvider,
395
+ } from '@llm-newsletter-kit/core';
242
396
 
243
- ## Architecture & Flow
397
+ import { DateType, GenerateNewsletter } from '@llm-newsletter-kit/core';
398
+ ```
244
399
 
245
- 1. CrawlingChain: Collect/parse/save articles from targets
246
- 2. AnalysisChain: Tagging/image analysis/importance scoring and update
247
- 3. ContentGenerateChain: Select candidates → generate Markdown via LLM → apply template (HTML) → save → return id
248
- 4. If previewNewsletter option is present, send a preview email
400
+ `GenerateNewsletter<TaskId>.generate()` returns `Promise<string | number | null>`. Internal chain classes, LLM query classes, `LoggingExecutor`, and utilities are not exported by the package. Nested option types can be derived from exported types, for example `GenerateNewsletterConfig<string>['contentOptions']`.
249
401
 
250
- All chains are composed as a single pipeline using `@langchain/core/runnables` sequence.
402
+ ## Architecture & Flow
251
403
 
252
- ![diagram.png](assets/diagram.png)
404
+ 1. Start the task using `TaskService`.
405
+ 2. Crawl list pages → parse → exclude stored URLs → fetch/parse detail pages → merge and save articles.
406
+ 3. Read unscored articles and tags → classify tags and analyze images in parallel → score importance → persist updates.
407
+ 4. Read generation candidates → check publication criteria → generate structured title/Markdown → render HTML and inline CSS → save newsletter and return its ID.
408
+ 5. End the task, including when the pipeline fails.
409
+ 6. If configured and a newsletter was created, fetch it and send a preview email.
410
+
411
+ The three main chains are composed with `@langchain/core/runnables`. AI SDK `generateText` with `Output.object` and Zod schemas handles LLM output. The shared wrapper sets Anthropic's structured-output mode to `jsonTool`.
412
+
413
+ ```mermaid
414
+ flowchart TD
415
+ Start["TaskService.start()"] --> Crawl["Crawl and persist articles"]
416
+ Crawl --> Analyze["Analyze and persist insights"]
417
+ Analyze --> Generate["Check criteria, generate, render, and save"]
418
+ Generate --> End["TaskService.end()"]
419
+ Crawl -. "unhandled error" .-> Cleanup["TaskService.end() in finally"]
420
+ Analyze -. "unhandled error" .-> Cleanup
421
+ Generate -. "unhandled error" .-> Cleanup
422
+ Cleanup --> Reject["Reject generate()"]
423
+ End --> Preview{"ID returned and preview configured?"}
424
+ Preview -- Yes --> Send["Fetch newsletter and send preview; log delivery failures"]
425
+ Preview -- No --> Return["Return ID or null"]
426
+ Send --> Return
427
+ ```
253
428
 
254
429
  ## Crawling & Parsing Philosophy: "Bring Your Own Scraper"
255
430
 
@@ -259,16 +434,28 @@ This kit prioritizes flexibility over rigid tooling. Instead of locking you into
259
434
  - **Asynchronous Injection:** Parsing logic is injected asynchronously, allowing you to integrate third-party APIs or AI-based parsers effortlessly.
260
435
  - **Recommendation:** While the kit supports LLM-based parsing (HTML-to-JSON), we generally recommend **rule-based parsing** (e.g., CSS selectors) for production environments to ensure speed, cost-efficiency, and stability.
261
436
 
437
+ ### Crawling behavior
438
+
439
+ A `CrawlingTarget` requires `id`, `name`, `url`, `parseList(html)`, and `parseDetail(html)`; either parser can return a value or a promise. Group targets under unique group names, which are used as pipeline keys. The core fetches HTML before invoking parsers; browser rendering or proxy access must be supplied by your integration, such as `customFetch?: typeof fetch`.
440
+
441
+ - Groups run in parallel. `maxConcurrency` limits target pipelines **per group**, not total HTTP requests. Detail pages within a target are fetched concurrently.
442
+ - Existing records are excluded by exact `detailUrl` using `fetchExistingArticlesByUrls`. This is not content-based deduplication or a uniqueness guarantee for duplicates within the same fetched batch.
443
+ - The HTTP helper makes up to five attempts for retryable errors, including 429/5xx and recognized network/timeout errors. It uses backoff with jitter, `Retry-After`, randomized User-Agent headers, and an increasing abort timeout for the fetch request. Other 4xx responses are not retried by the helper.
444
+ - List fetch/parse failures are logged and yield an empty list for that target. Failed detail fetches and rejected detail-parse promises are logged and omitted while successful items continue. A synchronous throw from `parseDetail` can instead trigger the target pipeline retry.
445
+ - Persistence may receive an empty array. Merge processing replaces ASCII double quotes in titles and detail content with smart quotes before saving.
446
+
447
+ During analysis, existing complete tags and image context are reused. Image analysis reads up to five Markdown image references from `detailContent`; no image flag or no matching references means no image request. Default prompts treat image output as supplementary context rather than precise transcription. Tag/image failures are tolerated, and an importance-query failure falls back to score 1.
448
+
262
449
  ## Playground
263
450
 
264
451
  Playground scripts let you run individual LLM query classes in isolation — no full pipeline needed. Useful for prompt tuning, testing new options, or debugging output quality.
265
452
 
266
453
  ### Setup
267
454
 
268
- 1. Install playground dependencies:
455
+ 1. From a checkout of this repository, install dependencies (the four provider adapters and `tsx` are already declared):
269
456
 
270
457
  ```bash
271
- npm install -D tsx @ai-sdk/openai
458
+ npm ci
272
459
  ```
273
460
 
274
461
  2. Copy example data files and customize:
@@ -280,7 +467,7 @@ Playground scripts let you run individual LLM query classes in isolation — no
280
467
  cp playground/data-examples/template.example.html playground/data/template.html
281
468
  ```
282
469
 
283
- 3. Edit `playground/data/config.json` with your OpenAI API key and options.
470
+ 3. Edit `playground/data/config.json` with your provider, API key, available model ID, and options. Supported playground providers are `openai`, `anthropic`, `google`, and `togetherai`. The example overrides newsletter generation with Anthropic: replace that key/model or remove the `models.generateNewsletter` override. Set `isoDate` and `displayDate` to the intended publication date.
284
471
  4. Edit `playground/data/articles.json` with your target articles. Both camelCase field names (`detailContent`, `targetUrl`, …) and snake_case DB dumps (`detail_content`, `target_url`, …) are accepted — snake_case records are normalized automatically.
285
472
  5. (Optional) Replace `playground/data/template.html` with your actual email template.
286
473
 
@@ -292,7 +479,7 @@ To test custom LLM prompts per pipeline stage (the `PromptProvider` interface),
292
479
  cp playground/data-examples/prompts.example.ts playground/data/prompts.ts
293
480
  ```
294
481
 
295
- Every builder in `prompts.ts` is optional — delete the ones you don't want to override and those stages fall back to the built-in default prompts. Each playground script logs whether it is running with custom or default prompts.
482
+ Every builder in `prompts.ts` is optional — delete the ones you don't want to override and those stages fall back to the built-in default prompts. The module must default-export a `PromptProvider` object. Each LLM playground script logs whether it is running with custom or default prompts.
296
483
 
297
484
  Prompt builders receive their values (`expertFields`, `outputLanguage`, `targetArticle`, `dateService`, …) as arguments rather than reading config directly, so a prompt module written this way works unchanged across different configurations. Those argument values come from `config.json` and `articles.json`.
298
485
 
@@ -304,18 +491,20 @@ Production pipelines often mix providers — a cheap model for tagging, a multim
304
491
  {
305
492
  "provider": "openai",
306
493
  "apiKey": "sk-...",
307
- "model": "gpt-5.4-nano",
494
+ "model": "YOUR_OPENAI_MODEL",
308
495
  "models": {
309
- "analyzeImages": { "model": "gpt-5.4-nano" },
496
+ "analyzeImages": { "model": "YOUR_OPENAI_MODEL" },
310
497
  "generateNewsletter": {
311
498
  "provider": "anthropic",
312
499
  "apiKey": "sk-ant-...",
313
- "model": "claude-sonnet-4-6"
500
+ "model": "YOUR_ANTHROPIC_MODEL"
314
501
  }
315
502
  }
316
503
  }
317
504
  ```
318
505
 
506
+ Use model IDs available to your account. A stage override inherits each omitted field independently, so changing `provider` also requires the appropriate API key and model. The core itself accepts injected AI SDK `LanguageModel` instances and is not limited to these four playground adapters.
507
+
319
508
  ### Generation options (optional)
320
509
 
321
510
  Sampling and output limits for the newsletter generation stage. Omitted values use the core defaults (`temperature: 0.3`, everything else unset):
@@ -342,6 +531,16 @@ npm run playground:determine-importance # ❸ Importance scoring
342
531
  npm run playground:generate-newsletter # ❹ Newsletter generation
343
532
  ```
344
533
 
534
+ The scripts read the same input file independently: they do not write analysis results back into `articles.json` or chain their outputs. Update the input data yourself when testing later stages. The generation playground bypasses publication criteria, task services, database persistence, and email delivery. Playground SDK retries default to 3, compared with 5 in the full pipeline.
535
+
536
+ To compare a custom prompt module with the built-in prompts without LLM calls:
537
+
538
+ ```bash
539
+ npm run playground:verify-prompts
540
+ ```
541
+
542
+ This requires the local config and at least one article. It compares rendered strings across predefined scenarios and exits with code 1 if they differ. Intentional customizations can therefore fail this equivalence check; it is not a prompt-quality evaluation.
543
+
345
544
  ### Output
346
545
 
347
546
  Results are saved to `playground/output/` (git-ignored):
@@ -363,7 +562,20 @@ Results are saved to `playground/output/` (git-ignored):
363
562
 
364
563
  ## Development / Build / Test / CI
365
564
 
366
- For the full developer guide (environment, scripts, testing/coverage, and CI), see [CONTRIBUTING.md](./CONTRIBUTING.md).
565
+ Use Node.js 24 or newer and npm:
566
+
567
+ ```bash
568
+ npm ci
569
+ npm run format:check
570
+ npm run lint:ci
571
+ npm run typecheck
572
+ npm run build
573
+ npm run test:ci
574
+ ```
575
+
576
+ The [CI workflow](./.github/workflows/ci.yml) runs these checks on pull requests to any branch and manual dispatches, using Node.js 24.x. Rollup emits ESM JavaScript, a source map, and bundled declarations in `dist/`. Vitest uses the Node environment and V8 coverage with 100% thresholds for lines, branches, functions, and statements; the entry point and model/type files are excluded from coverage.
577
+
578
+ For local iteration, use `npm run test:watch`, `npm run test:coverage`, `npm run lint:fix`, and `npm run format`. For the contribution workflow and release scripts, see [CONTRIBUTING.md](./CONTRIBUTING.md); [package.json](./package.json) defines the current runtime requirement and commands.
367
579
 
368
580
  ## Contributing & Policies
369
581
 
package/dist/index.js CHANGED
@@ -987,6 +987,17 @@ class AnalysisChain extends Chain {
987
987
  }
988
988
  }
989
989
 
990
+ /**
991
+ * Maximum number of generation attempts, including the first one.
992
+ *
993
+ * The self-verification fields in the output schema (isWrittenInOutputLanguage,
994
+ * copyrightVerified, factAccuracy) and the titleContext check each trigger a full
995
+ * regeneration — the most expensive call in the pipeline. A custom prompt that
996
+ * replaces the built-in one may never satisfy those fields, so the retry loop is
997
+ * capped rather than left unbounded. `llm.maxRetries` guards the AI SDK call and
998
+ * `chain.stopAfterAttempt` guards the chain; neither guards this loop.
999
+ */
1000
+ const MAX_GENERATION_ATTEMPTS = 5;
990
1001
  let GenerateNewsletter$1 = class GenerateNewsletter extends BaseLLMQuery {
991
1002
  maxOutputTokens;
992
1003
  temperature;
@@ -1027,6 +1038,18 @@ let GenerateNewsletter$1 = class GenerateNewsletter extends BaseLLMQuery {
1027
1038
  this.promptBuilder = config.promptBuilder;
1028
1039
  }
1029
1040
  async execute() {
1041
+ return this.executeAttempt(1);
1042
+ }
1043
+ /**
1044
+ * Runs one generation attempt and retries while the output fails self-verification.
1045
+ *
1046
+ * On the final attempt the last result is returned as-is instead of throwing —
1047
+ * an imperfect newsletter is more useful than none, and the caller has already
1048
+ * paid for it.
1049
+ *
1050
+ * @param attempt 1-based attempt number, capped at MAX_GENERATION_ATTEMPTS
1051
+ */
1052
+ async executeAttempt(attempt) {
1030
1053
  const { output, usage, finishReason } = await generateObjectByLLM({
1031
1054
  model: this.model,
1032
1055
  maxRetries: this.options.llm.maxRetries,
@@ -1050,8 +1073,24 @@ let GenerateNewsletter$1 = class GenerateNewsletter extends BaseLLMQuery {
1050
1073
  !output.factAccuracy ||
1051
1074
  (this.options.content.titleContext &&
1052
1075
  !output.title.includes(this.options.content.titleContext));
1076
+ if (needsRetry && attempt >= MAX_GENERATION_ATTEMPTS) {
1077
+ this.logger.debug({
1078
+ event: 'generate.newsletter.verification.exhausted',
1079
+ taskId: this.taskId,
1080
+ data: {
1081
+ attempts: attempt,
1082
+ isWrittenInOutputLanguage: output.isWrittenInOutputLanguage,
1083
+ copyrightVerified: output.copyrightVerified,
1084
+ factAccuracy: output.factAccuracy,
1085
+ titleContextMatched: this.options.content.titleContext
1086
+ ? output.title.includes(this.options.content.titleContext)
1087
+ : undefined,
1088
+ },
1089
+ });
1090
+ return { result: pick(output, ['title', 'content']), usage };
1091
+ }
1053
1092
  if (needsRetry) {
1054
- const retryResult = await this.execute();
1093
+ const retryResult = await this.executeAttempt(attempt + 1);
1055
1094
  return {
1056
1095
  result: retryResult.result,
1057
1096
  usage: addUsage(usage, retryResult.usage),
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@llm-newsletter-kit/core",
3
3
  "private": false,
4
4
  "type": "module",
5
- "version": "3.0.3",
5
+ "version": "3.0.5",
6
6
  "description": "An extensible framework to automate your entire newsletter workflow. Handles data collection, LLM-based content analysis, and email generation, letting you focus on your unique domain logic.",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",