@llm-newsletter-kit/core 3.0.4 → 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 +289 -77
- package/dist/index.js +40 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
- **
|
|
59
|
-
- **
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
91
|
-
// titleContext: 'AI Weekly',
|
|
133
|
+
freeFormIntro: true,
|
|
134
|
+
// titleContext: 'AI Weekly', // Required substring in the generated title
|
|
92
135
|
},
|
|
93
136
|
dateService: {
|
|
94
|
-
getPublicationISODateString: () =>
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
saveCrawledArticles: async (articles, context) => articles.length,
|
|
145
|
+
crawlingTargetGroups: [],
|
|
146
|
+
fetchExistingArticlesByUrls: async () => [],
|
|
147
|
+
saveCrawledArticles: async () => 0,
|
|
107
148
|
},
|
|
108
149
|
analysisProvider: {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
119
|
-
model: openai('gpt-5.4-nano'),
|
|
162
|
+
model,
|
|
120
163
|
issueOrder: 1,
|
|
121
164
|
newsletterBrandName: 'Tech Insight Weekly',
|
|
122
|
-
publicationCriteria: {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
130
|
-
|
|
187
|
+
const newsletterId = await new GenerateNewsletter(config).generate();
|
|
188
|
+
console.log({ newsletterId, newsletter: newsletters.at(-1) });
|
|
131
189
|
```
|
|
132
190
|
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:**
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
+
## Public API Overview
|
|
227
385
|
|
|
228
|
-
|
|
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
|
-
|
|
388
|
+
```ts
|
|
389
|
+
import type {
|
|
390
|
+
AnalysisProvider,
|
|
391
|
+
ContentGenerateProvider,
|
|
392
|
+
CrawlingProvider,
|
|
393
|
+
GenerateNewsletterConfig,
|
|
394
|
+
PromptProvider,
|
|
395
|
+
} from '@llm-newsletter-kit/core';
|
|
242
396
|
|
|
243
|
-
|
|
397
|
+
import { DateType, GenerateNewsletter } from '@llm-newsletter-kit/core';
|
|
398
|
+
```
|
|
244
399
|
|
|
245
|
-
|
|
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
|
-
|
|
402
|
+
## Architecture & Flow
|
|
251
403
|
|
|
252
|
-
|
|
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.
|
|
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
|
|
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
|
|
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": "
|
|
494
|
+
"model": "YOUR_OPENAI_MODEL",
|
|
308
495
|
"models": {
|
|
309
|
-
"analyzeImages": { "model": "
|
|
496
|
+
"analyzeImages": { "model": "YOUR_OPENAI_MODEL" },
|
|
310
497
|
"generateNewsletter": {
|
|
311
498
|
"provider": "anthropic",
|
|
312
499
|
"apiKey": "sk-ant-...",
|
|
313
|
-
"model": "
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|