@llm-newsletter-kit/core 2.1.0 → 3.0.1

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/dist/index.cjs DELETED
@@ -1,2047 +0,0 @@
1
- 'use strict';
2
-
3
- var runnables = require('@langchain/core/runnables');
4
- var esToolkit = require('es-toolkit');
5
- var zod = require('zod');
6
- var ai = require('ai');
7
- var jsdom = require('jsdom');
8
- var safeMarkdown2Html = require('safe-markdown2html');
9
- var node_crypto = require('node:crypto');
10
-
11
- /**
12
- * Common type aliases.
13
- *
14
- * - Provides explicit alias types for date/URL/Markdown/HTML, etc.
15
- * - All comments are written in English JSDoc style.
16
- */
17
- /**
18
- * Type for date identifiers.
19
- *
20
- * The DateType enum is used to distinguish date-related values.
21
- * It can be used to differentiate between registered dates and ranges.
22
- *
23
- * Enum members:
24
- * - REGISTERED: indicates a registered date.
25
- * - DURATION: indicates a duration or time range.
26
- *
27
- * @example
28
- * ```ts
29
- * const type: DateType = DateType.REGISTERED;
30
- * ```
31
- */
32
- exports.DateType = void 0;
33
- (function (DateType) {
34
- DateType["REGISTERED"] = "registered";
35
- DateType["DURATION"] = "duration";
36
- })(exports.DateType || (exports.DateType = {}));
37
-
38
- /**
39
- * Executor that provides a standardized start/done/error logging pattern.
40
- * - Uses the injected logger and taskId to attach common fields to every log.
41
- * - Pass config.event as a prefix like "crawl.group";
42
- * ".start"/".done"/".error" are appended automatically.
43
- */
44
- class LoggingExecutor {
45
- logger;
46
- taskId;
47
- constructor(logger, taskId) {
48
- this.logger = logger;
49
- this.taskId = taskId;
50
- }
51
- async executeWithLogging(config, fn) {
52
- const level = config.level ?? 'debug';
53
- const startedAt = Date.now();
54
- const startMsg = {
55
- event: `${config.event}.start`,
56
- level,
57
- taskId: this.taskId,
58
- data: config.startFields ?? {},
59
- };
60
- this.logger[level](startMsg);
61
- try {
62
- const result = await fn();
63
- const durationMs = Date.now() - startedAt;
64
- const doneExtra = config.doneFields
65
- ? (config.doneFields(result) ?? {})
66
- : {};
67
- const doneMsg = {
68
- event: `${config.event}.done`,
69
- level,
70
- taskId: this.taskId,
71
- durationMs,
72
- data: { ...(config.startFields ?? {}), ...doneExtra },
73
- };
74
- this.logger[level](doneMsg);
75
- return result;
76
- }
77
- catch (err) {
78
- const durationMs = Date.now() - startedAt;
79
- const errorMsg = {
80
- event: `${config.event}.error`,
81
- level,
82
- taskId: this.taskId,
83
- durationMs,
84
- data: { ...(config.startFields ?? {}) },
85
- };
86
- this.logger[level](errorMsg);
87
- this.logger.error(err);
88
- throw err;
89
- }
90
- }
91
- }
92
-
93
- async function generateObjectByLLM({ schema, ...rest }) {
94
- const result = await ai.generateText({
95
- ...rest,
96
- output: ai.Output.object({ schema }),
97
- // Anthropic truncates structured output when it contains quotation marks.
98
- // Using 'jsonTool' mode avoids this issue.
99
- providerOptions: {
100
- anthropic: {
101
- structuredOutputMode: 'jsonTool',
102
- },
103
- },
104
- });
105
- return {
106
- output: result.output,
107
- usage: result.usage,
108
- finishReason: result.finishReason,
109
- };
110
- }
111
-
112
- /**
113
- * Replaces ASCII double quotes with Unicode smart quotes (U+201C, U+201D).
114
- * Prevents JSON structured output corruption when LLM reproduces quoted titles.
115
- */
116
- const replaceAsciiQuotes = (str) => {
117
- let isOpen = true;
118
- return str.replace(/"/g, () => {
119
- const q = isOpen ? '\u201c' : '\u201d';
120
- isOpen = !isOpen;
121
- return q;
122
- });
123
- };
124
- const ensureStringArray = (value) => {
125
- return typeof value === 'string' ? [value] : value;
126
- };
127
- const isBrTag = (value) => /^<br\s*\/?>$/i.test(value);
128
- const ensureHrBeforeH2 = (markdown) => {
129
- const lines = markdown.split('\n');
130
- const result = [];
131
- let hasContentBefore = false;
132
- for (const line of lines) {
133
- const trimmed = line.trim();
134
- if (line.startsWith('## ') && hasContentBefore) {
135
- let alreadyHasHr = false;
136
- for (let i = result.length - 1; i >= 0; i--) {
137
- const prev = result[i].trim();
138
- if (prev !== '' && !isBrTag(prev)) {
139
- alreadyHasHr = prev === '---';
140
- break;
141
- }
142
- }
143
- if (!alreadyHasHr) {
144
- result.push('---');
145
- }
146
- }
147
- else if (trimmed !== '' && trimmed !== '---' && !isBrTag(trimmed)) {
148
- hasContentBefore = true;
149
- }
150
- result.push(line);
151
- }
152
- return result.join('\n');
153
- };
154
-
155
- class BaseLLMQuery {
156
- model;
157
- expertFields;
158
- logger;
159
- taskId;
160
- options;
161
- executeWithLogging;
162
- constructor(config) {
163
- this.model = config.model;
164
- this.expertFields = ensureStringArray(config.options.content.expertField);
165
- this.logger = config.logger;
166
- this.taskId = config.taskId;
167
- this.options = config.options;
168
- this.executeWithLogging = config.loggingExecutor.executeWithLogging.bind(config.loggingExecutor);
169
- }
170
- }
171
- class LLMQuery extends BaseLLMQuery {
172
- targetArticle;
173
- constructor(config) {
174
- super(config);
175
- this.targetArticle = config.targetArticle;
176
- }
177
- }
178
-
179
- const ZERO_USAGE = {
180
- inputTokens: undefined,
181
- inputTokenDetails: {
182
- noCacheTokens: undefined,
183
- cacheReadTokens: undefined,
184
- cacheWriteTokens: undefined,
185
- },
186
- outputTokens: undefined,
187
- outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
188
- totalTokens: undefined,
189
- };
190
- class AnalyzeImages extends LLMQuery {
191
- schema = zod.z.object({
192
- imageContext: zod.z
193
- .string()
194
- .describe('A high-level overview of the general theme, mood, and context conveyed by the images (without specific names, titles, or exact figures from text within images)'),
195
- });
196
- promptBuilder;
197
- constructor(config) {
198
- super(config);
199
- this.promptBuilder = config.promptBuilder;
200
- }
201
- async execute() {
202
- if (!this.targetArticle.hasAttachedImage ||
203
- !this.targetArticle.detailContent) {
204
- return { result: null, usage: ZERO_USAGE };
205
- }
206
- if (this.imageMessages.length === 0) {
207
- return { result: null, usage: ZERO_USAGE };
208
- }
209
- const { output, usage } = await generateObjectByLLM({
210
- model: this.model,
211
- maxRetries: this.options.llm.maxRetries,
212
- schema: this.schema,
213
- system: this.systemPrompt,
214
- messages: [
215
- {
216
- role: 'user',
217
- content: [this.textMessage, ...this.imageMessages],
218
- },
219
- ],
220
- });
221
- return { result: output.imageContext, usage };
222
- }
223
- get promptContext() {
224
- return {
225
- expertFields: this.expertFields,
226
- outputLanguage: this.options.content.outputLanguage,
227
- targetArticle: this.targetArticle,
228
- };
229
- }
230
- get systemPrompt() {
231
- if (this.promptBuilder?.system) {
232
- return this.promptBuilder.system(this.promptContext);
233
- }
234
- return `# Image Analysis Expert System
235
-
236
- ## Identity & Expertise
237
- You are a specialized image analysis expert in: ${this.expertFields.join(', ')}
238
-
239
- ## Core Responsibilities
240
- 1. Extract visual information unavailable from text alone
241
- 2. Identify industry-specific elements, facilities, and stakeholders
242
- 3. Summarize the general theme and context of charts, data visualizations, and text within images at a high level
243
- 4. Synthesize visual information with article context
244
-
245
- ## Critical Rule: NO Detailed Text Transcription
246
- - Do NOT transcribe specific names, titles, numbers, dates, or exact phrases from text within images
247
- - Instead, describe WHAT KIND of information is present (e.g., "a conference poster listing multiple speaker sessions" instead of transcribing each speaker's name and talk title)
248
- - Specific text in images is prone to misreading — only describe the general nature and purpose of such text
249
- - Focus on the overall theme, mood, and context that the images convey
250
-
251
- ## Analysis Framework
252
-
253
- ### Information Categories to Extract
254
- - Industry-relevant visual elements
255
- - General theme and purpose of text/data shown in images (without exact transcription)
256
- - Key subjects (people, places, objects, infrastructure) described at a general level
257
- - Contextual relationships to ${this.expertFields.join(', ')}
258
- - Information gaps filled by visual analysis
259
-
260
- ### Quality Standards
261
- - High-level accuracy in describing the overall context
262
- - Professional relevance for industry practitioners
263
- - Integration with accompanying text content
264
- - Preference for general descriptions over specific details from image text
265
-
266
- ## Output Specifications
267
- - Language: ${this.options.content.outputLanguage}
268
- - Format: Single cohesive explanation (not numbered list)
269
- - Focus: High-level context and practical insights for industry professionals
270
- - Integration: Seamlessly merge all extracted information
271
- - Granularity: Describe the general nature of visual content — avoid quoting specific names, titles, figures, or exact text from images`;
272
- }
273
- get imageUrls() {
274
- // Markdown image pattern: ![alt text](url) or ![](url)
275
- // Includes http, https, relative paths, and data URIs
276
- const imageRegex = /!\[.*?\]\(([^)]+)\)/g;
277
- const urls = [];
278
- let match;
279
- while ((match = imageRegex.exec(this.targetArticle.detailContent)) !== null) {
280
- const url = match[1].trim();
281
- // Validate URL format (http, https, relative path, data URI)
282
- if (url &&
283
- (url.startsWith('http://') ||
284
- url.startsWith('https://') ||
285
- url.startsWith('//') || // Protocol-relative URL
286
- url.startsWith('/') || // Absolute path
287
- url.startsWith('./') || // Relative path
288
- url.startsWith('../') || // Parent directory relative path
289
- url.startsWith('data:image/')) // Data URI
290
- ) {
291
- urls.push(url);
292
- }
293
- }
294
- // Process max 5 images only (to save cost)
295
- return urls.slice(0, 5);
296
- }
297
- get imageMessages() {
298
- return this.imageUrls.map((url) => ({
299
- type: 'image',
300
- image: url,
301
- }));
302
- }
303
- get textMessage() {
304
- if (this.promptBuilder?.user) {
305
- return {
306
- type: 'text',
307
- text: this.promptBuilder.user(this.promptContext),
308
- };
309
- }
310
- return {
311
- type: 'text',
312
- text: `## Analysis Task
313
-
314
- **Document Context:**
315
- - Title: ${this.targetArticle.title}
316
- - Content: ${this.targetArticle.detailContent}
317
-
318
- ## Instructions
319
-
320
- Analyze the provided images and synthesize your findings into a single comprehensive explanation that:
321
-
322
- 1. **Identifies Visual Content**: Extract industry-specific elements, infrastructure, and stakeholders relevant to ${this.expertFields.join(', ')}
323
-
324
- 2. **Summarizes Visual Text & Data**: Describe the general theme and purpose of any text, numerical data, charts, or graphs shown in images — do NOT transcribe specific names, titles, exact numbers, or quoted phrases
325
-
326
- 3. **Describes Visual Elements**: Detail important subjects (people, places, objects) and their significance at a general level
327
-
328
- 4. **Establishes Connections**: Link visual information to ${this.expertFields.join(', ')} context and article content
329
-
330
- 5. **Provides Context**: Explain what industry professionals should understand from these images
331
-
332
- 6. **Complements Text**: Add visual insights not covered in the article text
333
-
334
- **Important**: Do NOT attempt to exactly transcribe text within images (names, titles, numbers, dates). Instead, describe what kind of information is shown. Exact text from images is unreliable and may contain errors.
335
-
336
- **Format**: Present all findings as one flowing narrative without enumeration.`,
337
- };
338
- }
339
- }
340
-
341
- class ClassifyTags extends LLMQuery {
342
- schema = zod.z.object({
343
- tag1: zod.z.string(),
344
- tag2: zod.z.string(),
345
- tag3: zod.z.string(),
346
- });
347
- promptBuilder;
348
- existTags = [];
349
- constructor(config) {
350
- super(config);
351
- this.promptBuilder = config.promptBuilder;
352
- }
353
- async execute({ existTags }) {
354
- this.existTags = existTags;
355
- const { output, usage } = await generateObjectByLLM({
356
- model: this.model,
357
- maxRetries: this.options.llm.maxRetries,
358
- schema: this.schema,
359
- system: this.systemPrompt,
360
- prompt: this.userPrompt,
361
- });
362
- return { result: output, usage };
363
- }
364
- get promptContext() {
365
- return {
366
- expertFields: this.expertFields,
367
- outputLanguage: this.options.content.outputLanguage,
368
- targetArticle: this.targetArticle,
369
- existTags: this.existTags,
370
- };
371
- }
372
- get systemPrompt() {
373
- if (this.promptBuilder?.system) {
374
- return this.promptBuilder.system(this.promptContext);
375
- }
376
- return `You are an AI specializing in analyzing and categorizing articles for professionals in ${this.expertFields.join(', ')}.
377
-
378
- ## Core Responsibility
379
- Analyze article titles and content to generate 3 optimal, detailed classifications by evaluating compatibility with existing tags and determining when new tags are justified.
380
-
381
- ## Output Language
382
- All classifications must be written in ${this.options.content.outputLanguage}.
383
-
384
- ## Classification Rules
385
- 1. **Reuse Threshold**: Use existing classifications if compatibility is 80% or higher
386
- 2. **New Tag Criteria**: Create new classifications only when:
387
- - Best existing match scores below 80% compatibility
388
- - New tag demonstrates versatility across 10+ similar articles
389
- 3. **Naming Standards**:
390
- - Length: 3-15 characters
391
- - Style: Clear, intuitive ${this.options.content.outputLanguage} terms
392
- - Balance industry precision with general reader comprehension
393
- 4. **Scope Exclusion**: Avoid broad, general tags like ${this.expertFields.map((v) => `"${v}"`).join(', ')} (too generic for this expert audience)
394
-
395
- ## Decision Framework
396
- Prioritize in order:
397
- - Content accuracy and relevance
398
- - Classification system consistency
399
- - User intuitiveness and searchability
400
- - Long-term scalability and maintainability`;
401
- }
402
- get userPrompt() {
403
- if (this.promptBuilder?.user) {
404
- return this.promptBuilder.user(this.promptContext);
405
- }
406
- return `**Task**: Classify this article with 3 optimal detailed tags.
407
-
408
- **Article Information**
409
- - Title: ${this.targetArticle.title}
410
- - Content: ${this.targetArticle.detailContent}
411
-
412
- **Available Existing Tags**
413
- \`\`\`
414
- ${JSON.stringify(this.existTags, null, 2)}
415
- \`\`\`
416
-
417
- **Analysis Steps**
418
- 1. Extract core concepts, industry sectors, and specific topics from the article
419
- 2. Score each existing tag for compatibility (0-100%)
420
- 3. Identify which existing tags meet the 80%+ threshold
421
- 4. For tags below 80%, determine if a new tag would better serve the content
422
- 5. Validate new tag names for clarity, length, and future applicability
423
-
424
- **Output Requirements**
425
- Return exactly 3 classifications following the system rules. Each classification should:
426
- - Match the article's core content
427
- - Fit logically within the overall classification system
428
- - Be immediately understandable to your target audience
429
- - Support future similar articles`;
430
- }
431
- }
432
-
433
- class DetermineArticleImportance extends LLMQuery {
434
- minimumImportanceScoreRules;
435
- schema = zod.z.object({
436
- importanceScore: zod.z
437
- .number()
438
- .min(1)
439
- .max(10)
440
- .describe('Article importance score (1-10, 10 is most important)'),
441
- });
442
- dateService;
443
- promptBuilder;
444
- constructor(config) {
445
- super(config);
446
- this.minimumImportanceScoreRules = config.minimumImportanceScoreRules ?? [];
447
- this.dateService = config.dateService;
448
- this.promptBuilder = config.promptBuilder;
449
- }
450
- async execute() {
451
- const { output, usage } = await generateObjectByLLM({
452
- model: this.model,
453
- maxRetries: this.options.llm.maxRetries,
454
- schema: this.schema,
455
- system: this.systemPrompt,
456
- prompt: this.userPrompt,
457
- });
458
- return { result: output.importanceScore, usage };
459
- }
460
- get minPoint() {
461
- const targetRule = this.minimumImportanceScoreRules.find(({ targetUrl }) => targetUrl === this.targetArticle.targetUrl);
462
- return targetRule?.minScore ?? 1;
463
- }
464
- get hasHigherMinimumScore() {
465
- return this.minPoint > 1;
466
- }
467
- get promptContext() {
468
- return {
469
- expertFields: this.expertFields,
470
- targetArticle: this.targetArticle,
471
- dateService: this.dateService,
472
- minimumImportanceScoreRules: this.minimumImportanceScoreRules,
473
- };
474
- }
475
- get systemPrompt() {
476
- if (this.promptBuilder?.system) {
477
- return this.promptBuilder.system(this.promptContext);
478
- }
479
- return `You are an expert in importance evaluation in the field of ${this.expertFields.join(', ')}.
480
-
481
- Role:
482
- - Analyze titles and content in depth to objectively evaluate the importance of news and announcements.
483
- - Extract the most important insights for industry professionals. Main readers are practitioners from research institutions, local/public officials, graduate students, and field experts in ${this.expertFields.join(', ')}.
484
- - Score based on urgency, impact, and scarcity of information.
485
-
486
- Importance Score Criteria (${this.minPoint}-10):
487
- 10: Information with immediate and significant impact on entire industry (e.g., major legislation passed, large budget allocation, critical discoveries/events that transform the field)
488
- 8-9: Information with important impact on many stakeholders (e.g., major policy changes, major findings/achievements released, large project announcements)
489
- 7-8: Very important academic/professional achievements or information in specific fields (e.g., journal publication/release, major research results announcement, professional report publication, important academic events, research database construction/release, designation of important field resources/assets, medium-scale bid information)
490
- 5-6: General important information limited to specific fields or regions (e.g., small project permits, general event notices, small-scale bids)
491
- 4-5: General industry news or small/medium-scale event information
492
- 2-3: Simple information sharing or repetitive daily news
493
- ${this.hasHigherMinimumScore ? '' : `1: **Information without current practical value** - Expired support programs, past events, invalid bid notices or recruitment information, notices that have lost practical value, or administrative/simple notices like "membership fee status", "meeting minutes", "internal schedule notices"`}
494
-
495
- Evaluation Criteria:
496
- - Academic Value: Journal publications, research reports, academic seminars/symposiums, research output presentations etc. minimum 7 points (knowledge base expansion and long-term reference value)
497
- - Practical Impact: Information requiring immediate response like policies, regulations, bids, recruitment
498
- - Impact Range: How many stakeholders are affected
499
- - Scarcity: How rare and exclusive the information is
500
- - Temporal Context: Practical value as of the Newsletter Publication Date considering deadlines, event schedules${this.hasHigherMinimumScore ? '' : ' (However, recent academic achievements maintain high scores)'}
501
- ${this.hasHigherMinimumScore
502
- ? ''
503
- : `
504
- **HARD RULE — Temporal Expiration (MUST):**
505
- - Compare the Newsletter Publication Date (given in the user prompt) against any deadline, application period, event date, bid closing date, recruitment period, or validity period mentioned in the article (and the Article Published Date when provided).
506
- - If the article's deadline/event has already passed relative to the Newsletter Publication Date, you MUST score it 1, regardless of other criteria. This overrides all other considerations.
507
- - Exception: Academic outputs (journal publications, published research, completed conference proceedings) retain their score based on reference value; do not downgrade these under this rule.
508
- - If no deadline/event is mentioned and no temporal cue is available, this rule does not apply.
509
- `}
510
- Important Notes:
511
- - Evaluate considering characteristics and context of ${this.expertFields.join(', ')} fields.
512
- - Be sensitive to core keywords, events, policies considered important in the field.`;
513
- }
514
- get userPrompt() {
515
- if (this.promptBuilder?.user) {
516
- return this.promptBuilder.user(this.promptContext);
517
- }
518
- return `Please rate the importance of this article from ${this.minPoint} to 10.
519
-
520
- **Newsletter Publication Date:** ${this.dateService.getPublicationISODateString()}${this.targetArticle.publishedDate
521
- ? `
522
- **Article Published Date:** ${this.targetArticle.publishedDate}`
523
- : ''}
524
-
525
- **Title:** ${this.targetArticle.title || 'No Title'}
526
-
527
- **Content:** ${this.targetArticle.detailContent || 'No Content'}
528
-
529
- **Tags:** ${this.targetArticle.tag1 || ''}, ${this.targetArticle.tag2 || ''}, ${this.targetArticle.tag3 || ''}
530
- ${this.targetArticle.imageContextByLlm
531
- ? `
532
- **Image Analysis:** ${this.targetArticle.imageContextByLlm}`
533
- : ''}${this.hasHigherMinimumScore
534
- ? ''
535
- : `
536
-
537
- Before assigning a score, explicitly check whether any deadline, application period, event date, bid closing date, or recruitment period in the article has already passed relative to the Newsletter Publication Date above. If so and the HARD RULE applies, assign 1.`}`;
538
- }
539
- }
540
-
541
- class BaseChain {
542
- logger;
543
- taskId;
544
- provider;
545
- options;
546
- executeWithLogging;
547
- constructor(config) {
548
- this.logger = config.logger;
549
- this.taskId = config.taskId;
550
- this.provider = config.provider;
551
- this.options = config.options;
552
- this.executeWithLogging = config.loggingExecutor.executeWithLogging.bind(config.loggingExecutor);
553
- }
554
- }
555
- class Chain extends BaseChain {
556
- }
557
- class PrivateChain extends BaseChain {
558
- }
559
-
560
- class ArticleInsightsChain extends PrivateChain {
561
- dateService;
562
- promptProvider;
563
- constructor(config) {
564
- super(config);
565
- this.dateService = config.dateService;
566
- this.promptProvider = config.promptProvider;
567
- }
568
- /* istanbul ignore next - pipeline arrow functions are exercised via higher-level tests */
569
- get chain() {
570
- return runnables.RunnablePassthrough.assign({
571
- generatedTags: () => this.classifyArticles(),
572
- generatedImageContextList: () => this.extractImageContext(),
573
- })
574
- .pipe({
575
- mergedArticles: ({ generatedTags, generatedImageContextList }) => this.mergeTagsAndImageContext(generatedTags, generatedImageContextList),
576
- })
577
- .pipe({
578
- determinedArticles: ({ mergedArticles }) => this.determineImportance(mergedArticles),
579
- });
580
- }
581
- async generateInsights() {
582
- const { determinedArticles: initial } = await this.chain.invoke({});
583
- const maxIterations = 5; // Maximum number of iterations to prevent infinite loop
584
- const reprocess = async (current, iteration) => {
585
- if (iteration >= maxIterations) {
586
- this.logger.debug({
587
- event: 'insights.warning.maxIterationsReached',
588
- taskId: this.taskId,
589
- data: {
590
- iterationCount: iteration,
591
- maxIterationCount: maxIterations,
592
- },
593
- });
594
- return current;
595
- }
596
- // Filter incomplete posts (where any of tag1, tag2, tag3, or importance_score is null)
597
- const incompleteArticles = current.filter((article) => !article.tag1 ||
598
- !article.tag2 ||
599
- !article.tag3 ||
600
- !article.importanceScore);
601
- if (incompleteArticles.length === 0) {
602
- this.logger.debug({
603
- event: 'insights.complete',
604
- taskId: this.taskId,
605
- });
606
- return current; // Exit when all posts have been fully processed
607
- }
608
- this.logger.debug({
609
- event: 'insights.incomplete.restart',
610
- taskId: this.taskId,
611
- data: {
612
- incompleteArticleCount: incompleteArticles.length,
613
- iterationCount: iteration,
614
- },
615
- });
616
- // Reprocess incomplete posts only by reusing an insight object
617
- const { determinedArticles: reprocessedArticles } = await this.chain.invoke({});
618
- // Update original determinedPosts with reprocessed articles
619
- const updated = current.map((article) => {
620
- const reprocessedArticle = reprocessedArticles.find((reArticle) => reArticle.id === article.id);
621
- return reprocessedArticle || article;
622
- });
623
- this.logger.debug({
624
- event: 'insights.incomplete.restart.done',
625
- taskId: this.taskId,
626
- data: {
627
- iterationCount: iteration,
628
- },
629
- });
630
- return reprocess(updated, iteration + 1);
631
- };
632
- return reprocess(initial, 0);
633
- }
634
- async classifyArticles() {
635
- return this.executeWithLogging({
636
- event: 'insights.articles.classify',
637
- level: 'debug',
638
- doneFields: (articles) => ({ count: articles }),
639
- }, async () => {
640
- const pushTag = (tag) => {
641
- if (tag && !this.provider.tags.includes(tag)) {
642
- this.provider.tags.push(tag);
643
- }
644
- };
645
- const articlesWithTags = [];
646
- for (const [i, article] of this.provider.unscoredArticles.entries()) {
647
- const existTags = this.provider.tags;
648
- if (article.tag1 && article.tag2 && article.tag3) {
649
- continue;
650
- }
651
- this.logger.debug({
652
- event: 'insights.articles.classify.start',
653
- taskId: this.taskId,
654
- data: {
655
- count: `${i + 1} / ${this.provider.unscoredArticles.length}`,
656
- articleId: article.id,
657
- title: article.title?.substring(0, 50) + '...',
658
- existingTags: existTags.length,
659
- },
660
- });
661
- try {
662
- const classifyTags = new ClassifyTags({
663
- ...this.getLlmQueryConfig(this.provider.classifyTagOptions.model, article),
664
- promptBuilder: this.promptProvider?.classifyTags,
665
- });
666
- const { result: generatedTags } = await classifyTags.execute({
667
- existTags,
668
- });
669
- pushTag(generatedTags.tag1);
670
- pushTag(generatedTags.tag2);
671
- pushTag(generatedTags.tag3);
672
- this.logger.debug({
673
- event: 'insights.articles.classify.end',
674
- taskId: this.taskId,
675
- data: {
676
- count: `${i + 1} / ${this.provider.unscoredArticles.length}`,
677
- articleId: article.id,
678
- result: `tag1: ${generatedTags.tag1}, tag2: ${generatedTags.tag2}, tag3: ${generatedTags.tag3}`,
679
- },
680
- });
681
- articlesWithTags.push({
682
- id: article.id,
683
- ...generatedTags,
684
- });
685
- }
686
- catch (error) {
687
- this.logger.debug({
688
- event: 'insights.articles.classify.end.error',
689
- taskId: this.taskId,
690
- data: {
691
- count: `${i + 1} / ${this.provider.unscoredArticles.length}`,
692
- articleId: article.id,
693
- error: error instanceof Error ? error.message : String(error),
694
- title: article.title?.substring(0, 50) + '...',
695
- },
696
- });
697
- // NOTE: Despite the error, it does not significantly hinder newsletter generation, so we proceed. Tagging helps produce a better newsletter, but it is not strictly required.
698
- }
699
- }
700
- return articlesWithTags;
701
- });
702
- }
703
- async extractImageContext() {
704
- return this.executeWithLogging({
705
- event: 'insights.images.extract',
706
- level: 'debug',
707
- doneFields: (articles) => ({ articles }),
708
- }, async () => {
709
- const articlesWithImageContext = [];
710
- for (const [i, article] of this.provider.unscoredArticles.entries()) {
711
- if (!article.hasAttachedImage) {
712
- this.logger.debug({
713
- event: 'insights.images.extract.pass.noimage',
714
- taskId: this.taskId,
715
- data: {
716
- articleId: article.id,
717
- },
718
- });
719
- continue;
720
- }
721
- if (article.imageContextByLlm) {
722
- this.logger.debug({
723
- event: 'insights.images.extract.pass.exist',
724
- taskId: this.taskId,
725
- data: {
726
- articleId: article.id,
727
- },
728
- });
729
- continue;
730
- }
731
- this.logger.debug({
732
- event: 'insights.images.extract.start',
733
- taskId: this.taskId,
734
- data: {
735
- count: `${i + 1} / ${this.provider.unscoredArticles.length}`,
736
- articleId: article.id,
737
- },
738
- });
739
- try {
740
- const analyzeImages = new AnalyzeImages({
741
- ...this.getLlmQueryConfig(this.provider.analyzeImagesOptions.model, article),
742
- promptBuilder: this.promptProvider?.analyzeImages,
743
- });
744
- const { result: imageContextByLlm } = await analyzeImages.execute();
745
- if (imageContextByLlm) {
746
- articlesWithImageContext.push({
747
- id: article.id,
748
- imageContextByLlm,
749
- });
750
- this.logger.debug({
751
- event: 'insights.images.extract.end',
752
- taskId: this.taskId,
753
- data: {
754
- count: `${i + 1} / ${this.provider.unscoredArticles.length}`,
755
- articleId: article.id,
756
- },
757
- });
758
- }
759
- else {
760
- this.logger.debug({
761
- event: 'insights.images.extract.end.noimage',
762
- taskId: this.taskId,
763
- data: {
764
- count: `${i + 1} / ${this.provider.unscoredArticles.length}`,
765
- articleId: article.id,
766
- },
767
- });
768
- }
769
- }
770
- catch (error) {
771
- this.logger.debug({
772
- event: 'insights.images.extract.end.error',
773
- taskId: this.taskId,
774
- data: {
775
- count: `${i + 1} / ${this.provider.unscoredArticles.length}`,
776
- articleId: article.id,
777
- error: error instanceof Error ? error.message : String(error),
778
- },
779
- });
780
- // NOTE: Image analysis failure should not interrupt the overall process
781
- }
782
- }
783
- return articlesWithImageContext;
784
- });
785
- }
786
- async mergeTagsAndImageContext(generatedTags, generatedImageContextList) {
787
- return this.executeWithLogging({
788
- event: 'insights.context.merge',
789
- level: 'debug',
790
- startFields: {
791
- generatedTags,
792
- generatedImageContextList,
793
- },
794
- doneFields: (count) => ({ count }),
795
- }, async () => {
796
- return this.provider.unscoredArticles.map((article) => {
797
- const articleWithTags = generatedTags.find(({ id }) => id === article.id);
798
- const articleWithImageContext = generatedImageContextList.find(({ id }) => id === article.id);
799
- if (articleWithTags) {
800
- article = {
801
- ...article,
802
- tag1: articleWithTags.tag1,
803
- tag2: articleWithTags.tag2,
804
- tag3: articleWithTags.tag3,
805
- };
806
- }
807
- if (articleWithImageContext) {
808
- article = {
809
- ...article,
810
- imageContextByLlm: articleWithImageContext.imageContextByLlm,
811
- };
812
- }
813
- return article;
814
- });
815
- });
816
- }
817
- async determineImportance(mergedArticles) {
818
- return this.executeWithLogging({
819
- event: 'insights.importance.determine',
820
- level: 'debug',
821
- startFields: {
822
- mergedArticles,
823
- },
824
- doneFields: (articles) => ({ articles }),
825
- }, async () => {
826
- const determinedArticles = [];
827
- for (const [i, article] of mergedArticles.entries()) {
828
- this.logger.debug({
829
- event: 'insights.importance.determine.start',
830
- taskId: this.taskId,
831
- data: {
832
- count: `${i + 1} / ${mergedArticles.length}`,
833
- articleId: article.id,
834
- title: article.title?.substring(0, 50) + '...',
835
- },
836
- });
837
- try {
838
- const determineArticleImportance = new DetermineArticleImportance({
839
- ...this.getLlmQueryConfig(this.provider.determineScoreOptions.model, article),
840
- minimumImportanceScoreRules: this.provider.determineScoreOptions.minimumImportanceScoreRules,
841
- dateService: this.dateService,
842
- promptBuilder: this.promptProvider?.determineImportance,
843
- });
844
- const { result: importanceScore } = await determineArticleImportance.execute();
845
- const processedArticle = {
846
- ...article,
847
- importanceScore,
848
- };
849
- // Push result first to avoid losing it if logging fails
850
- determinedArticles.push(processedArticle);
851
- // Best-effort logging that won't affect the result
852
- try {
853
- this.logger.debug({
854
- event: 'insights.importance.determine.end',
855
- taskId: this.taskId,
856
- data: {
857
- count: `${i + 1} / ${mergedArticles.length}`,
858
- articleId: article.id,
859
- importanceScore: importanceScore,
860
- },
861
- });
862
- }
863
- catch {
864
- // ignore logging errors
865
- }
866
- }
867
- catch (error) {
868
- // Log error but ensure we still return a fallback score
869
- try {
870
- this.logger.debug({
871
- event: 'insights.importance.determine.end.error',
872
- taskId: this.taskId,
873
- data: {
874
- count: `${i + 1} / ${mergedArticles.length}`,
875
- articleId: article.id,
876
- error: error instanceof Error ? error.message : String(error),
877
- },
878
- });
879
- }
880
- catch {
881
- // ignore logging errors
882
- }
883
- // NOTE: Importance analysis failure should not stop the pipeline; use a fallback score instead
884
- determinedArticles.push({
885
- ...article,
886
- importanceScore: 1, // Set to minimum importance as a sane default
887
- });
888
- }
889
- }
890
- return determinedArticles;
891
- });
892
- }
893
- getLlmQueryConfig(model, targetArticle) {
894
- return {
895
- model,
896
- logger: this.logger,
897
- taskId: this.taskId,
898
- targetArticle: targetArticle,
899
- options: esToolkit.pick(this.options, ['content', 'llm']),
900
- loggingExecutor: new LoggingExecutor(this.logger, this.taskId),
901
- };
902
- }
903
- }
904
-
905
- class AnalysisChain extends Chain {
906
- dateService;
907
- promptProvider;
908
- constructor(config) {
909
- super(config);
910
- this.dateService = config.dateService;
911
- this.promptProvider = config.promptProvider;
912
- }
913
- get chain() {
914
- return runnables.RunnablePassthrough.assign({
915
- unscoredArticles: () => this.fetchUnscoredArticles(),
916
- tags: () => this.fetchTags(),
917
- })
918
- .pipe(runnables.RunnablePassthrough.assign({
919
- determinedArticles: ({ unscoredArticles, tags }) => this.analyzeArticles(unscoredArticles, tags),
920
- }))
921
- .pipe(runnables.RunnablePassthrough.assign({
922
- processedCount: ({ determinedArticles }) => this.updateAnalysisContext(determinedArticles),
923
- }))
924
- .withRetry({ stopAfterAttempt: this.options.chain.stopAfterAttempt });
925
- }
926
- async fetchUnscoredArticles() {
927
- return this.executeWithLogging({
928
- event: 'analysis.articles.fetch',
929
- level: 'debug',
930
- doneFields: (items) => ({ count: items.length }),
931
- }, async () => {
932
- return await this.provider.fetchUnscoredArticles();
933
- });
934
- }
935
- async fetchTags() {
936
- return this.executeWithLogging({
937
- event: 'analysis.tags.fetch',
938
- level: 'debug',
939
- doneFields: (items) => ({ count: items.length }),
940
- }, async () => {
941
- return await this.provider.fetchTags();
942
- });
943
- }
944
- async analyzeArticles(unscoredArticles, tags) {
945
- return this.executeWithLogging({
946
- event: 'analysis.articles.analyze',
947
- level: 'debug',
948
- startFields: {
949
- unscoredArticles,
950
- tags,
951
- },
952
- doneFields: (items) => ({ count: items.length }),
953
- }, async () => {
954
- const articleInsightsChain = new ArticleInsightsChain({
955
- logger: this.logger,
956
- taskId: this.taskId,
957
- provider: {
958
- unscoredArticles,
959
- tags,
960
- classifyTagOptions: this.provider.classifyTagOptions,
961
- analyzeImagesOptions: this.provider.analyzeImagesOptions,
962
- determineScoreOptions: this.provider.determineScoreOptions,
963
- },
964
- options: this.options,
965
- loggingExecutor: new LoggingExecutor(this.logger, this.taskId),
966
- dateService: this.dateService,
967
- promptProvider: this.promptProvider,
968
- });
969
- return await articleInsightsChain.generateInsights();
970
- });
971
- }
972
- async updateAnalysisContext(determinedArticles) {
973
- return this.executeWithLogging({
974
- event: 'analysis.articles.update',
975
- level: 'debug',
976
- startFields: {
977
- determinedArticles,
978
- },
979
- doneFields: (count) => ({ count }),
980
- }, async () => {
981
- for (let i = 0; i < determinedArticles.length; i++) {
982
- const article = determinedArticles[i];
983
- await this.provider.update(article);
984
- }
985
- return determinedArticles.length;
986
- });
987
- }
988
- }
989
-
990
- let GenerateNewsletter$1 = class GenerateNewsletter extends BaseLLMQuery {
991
- maxOutputTokens;
992
- temperature;
993
- topP;
994
- topK;
995
- presencePenalty;
996
- frequencyPenalty;
997
- targetArticles;
998
- dateService;
999
- subscribePageUrl;
1000
- newsletterBrandName;
1001
- promptBuilder;
1002
- schema = zod.z.object({
1003
- title: zod.z.string().max(70).min(20).describe('Title of the newsletter email'),
1004
- content: zod.z.string().describe('Email content in markdown format'),
1005
- isWrittenInOutputLanguage: zod.z
1006
- .boolean()
1007
- .describe(`Whether the content is written in ${this.options.content.outputLanguage}`),
1008
- copyrightVerified: zod.z
1009
- .boolean()
1010
- .describe('Verification status of copyright compliance (true: verified, false: potential violation)'),
1011
- factAccuracy: zod.z
1012
- .boolean()
1013
- .describe('Verification of fact-based content from provided data (true: facts only, false: contains unsupported content)'),
1014
- });
1015
- constructor(config) {
1016
- super(config);
1017
- this.maxOutputTokens = config.maxOutputTokens;
1018
- this.temperature = config.temperature ?? 0.3;
1019
- this.topP = config.topP;
1020
- this.topK = config.topK;
1021
- this.presencePenalty = config.presencePenalty;
1022
- this.frequencyPenalty = config.frequencyPenalty;
1023
- this.targetArticles = config.targetArticles;
1024
- this.dateService = config.dateService;
1025
- this.subscribePageUrl = config.subscribePageUrl;
1026
- this.newsletterBrandName = config.newsletterBrandName;
1027
- this.promptBuilder = config.promptBuilder;
1028
- }
1029
- async execute() {
1030
- const { output, usage, finishReason } = await generateObjectByLLM({
1031
- model: this.model,
1032
- maxRetries: this.options.llm.maxRetries,
1033
- maxOutputTokens: this.maxOutputTokens,
1034
- temperature: this.temperature,
1035
- topP: this.topP,
1036
- topK: this.topK,
1037
- presencePenalty: this.presencePenalty,
1038
- frequencyPenalty: this.frequencyPenalty,
1039
- schema: this.schema,
1040
- system: this.systemPrompt,
1041
- prompt: this.userPrompt,
1042
- });
1043
- if (finishReason === 'length') {
1044
- throw new Error(`[GenerateNewsletter] Output truncated: LLM reached the token limit (finishReason: "length"). ` +
1045
- `Increase "maxOutputTokens" in ContentGenerateProvider or reduce the number of input articles. ` +
1046
- `Current maxOutputTokens: ${this.maxOutputTokens ?? 'not set (model default)'}`);
1047
- }
1048
- const needsRetry = !output.isWrittenInOutputLanguage ||
1049
- !output.copyrightVerified ||
1050
- !output.factAccuracy ||
1051
- (this.options.content.titleContext &&
1052
- !output.title.includes(this.options.content.titleContext));
1053
- if (needsRetry) {
1054
- const retryResult = await this.execute();
1055
- return {
1056
- result: retryResult.result,
1057
- usage: addUsage(usage, retryResult.usage),
1058
- };
1059
- }
1060
- return { result: esToolkit.pick(output, ['title', 'content']), usage };
1061
- }
1062
- get promptContext() {
1063
- return {
1064
- expertFields: this.expertFields,
1065
- outputLanguage: this.options.content.outputLanguage,
1066
- dateService: this.dateService,
1067
- targetArticles: this.targetArticles,
1068
- freeFormIntro: this.options.content.freeFormIntro,
1069
- titleContext: this.options.content.titleContext,
1070
- subscribePageUrl: this.subscribePageUrl,
1071
- newsletterBrandName: this.newsletterBrandName,
1072
- };
1073
- }
1074
- get systemPrompt() {
1075
- if (this.promptBuilder?.system) {
1076
- return this.promptBuilder.system(this.promptContext);
1077
- }
1078
- return `You are a newsletter production expert for "${this.newsletterBrandName}" who analyzes and delivers trends in the fields of ${this.expertFields.join(', ')}. Your goal is to provide in-depth analysis that helps industry professionals easily understand complex information and make informed decisions.
1079
-
1080
- Important rule for displaying date ranges: When displaying date ranges, you must use a hyphen (-) instead of a tilde (~). For example, use 'June 1-2, 2025' instead of 'June 1~2, 2025'. The tilde (~) can be rendered as strikethrough in markdown.
1081
-
1082
- **Key Principles for Preventing Hallucination:**
1083
- 1. **Fact-Based Writing**: Use only content explicitly stated in the provided sources, do not expand through inference or speculation.
1084
- 2. **Accurate Citation**: Use expressions directly from the sources without arbitrarily interpreting or elaborating on meanings.
1085
- 3. **Conservative Approach**: Do not mention uncertain or ambiguous content, or express it very cautiously.
1086
- 4. **Verifiable Information**: All information must be directly verifiable from the provided sources.
1087
- 5. **No Speculation**: Do not use speculative expressions like "appears to be" or "is expected to".
1088
- 6. **No Fictional Standards/Policies**: Do not mention non-existent standards/policies or systems incorrectly reported as planned for future implementation.
1089
- 7. **Image Analysis as Supplementary Context Only**: Image analysis results are provided solely for understanding the general visual context of articles. Do NOT quote specific names, titles, numbers, or detailed facts from image analysis in the newsletter body — these may contain inaccuracies. Only use image analysis to understand the broad theme or mood of the article's visual content.
1090
-
1091
- Roles:
1092
- - Friendly Guide: Deliver information like a trusted colleague rather than a rigid expert. Use appropriate emoticons in titles and section headings to improve readability.
1093
- - Information Integrator: Group similar topics or related news to show broader context and trends. Focus on connections between news items rather than individual stories, and explain patterns based on data.
1094
- - Credibility Builder: All information must be provided with sources. Whenever specific content or titles are mentioned in the body, links must be provided in [original title](URL) format. Understand that source citation is not just formal but a key element in enhancing newsletter credibility and accessibility.
1095
- - Fact Checker: Use only facts from provided source materials. Do not make unsubstantiated claims or speculate beyond the materials.
1096
-
1097
- **Temporal Validity (HARD RULE):**
1098
- The current issue's Newsletter Publication Date is provided in the user prompt. For every article you consider including:
1099
- - If the article's deadline, application period, event date, bid closing date, or recruitment period has already passed relative to the Newsletter Publication Date, you MUST EXCLUDE the article from the newsletter body entirely, regardless of its importance score.
1100
- - Exception: Academic outputs (published journal articles, research outputs, past conference proceedings with reference value) may be retained.
1101
- - If the article provides an Article Published Date and it is older than 30 days relative to the Newsletter Publication Date, treat it as suspicious for freshness and include only if it still has clear forward-looking relevance (e.g., an ongoing program whose deadline has not passed).
1102
- - Do not list or mention excluded articles even as "also notable" or in tables.
1103
-
1104
- **Important Prohibitions:**
1105
- - Do not bundle or omit structured list items (permits/reports/notices etc.) with "... and n more" etc. (tables must list all items in individual rows).
1106
- - Do not describe policies or plans of governments/organizations/companies not explicitly mentioned in sources as facts.
1107
- - Do not mention unconfirmed future plans or non-existent standards/policies.
1108
- - Do not add details not present in source materials.
1109
-
1110
- **Content Organization Principles:**
1111
- - Use only accurate content from provided sources
1112
- - No additional details or specific interpretations beyond source materials
1113
- - All information must be verifiable and traceable
1114
- - Focus on clear facts rather than inference or speculation
1115
- - Exclude uncertain content and include only confirmed information
1116
- - For tables, include as much identifying information as possible, but mark "—" if not in source
1117
- - No arbitrary estimation of domain-specific procedures or schedules (state only confirmed facts)
1118
-
1119
- Importance Score Criteria (1-10 points, expressed as stars):
1120
- - ★★★★★ (9-10 points): [Very Important] Laws/regulations that can change industry landscape, large budgets/investments, groundbreaking research/technology announcements that all professionals must know and prepare immediate responses for. These require immediate action and direct changes to business strategy.
1121
- - ★★★★☆ (7-8 points): [Important] Major policy changes, large projects/programs, important research/product announcements affecting specific fields or multiple organizations that should be referenced for key decisions. These need action soon and affect mid-term planning.
1122
- - ★★★☆☆ (5-6 points): [Reference] Medium-scale projects, services, approvals, major events/campaigns affecting specific regions or organizations. These are changes that professionals in the field should know about.
1123
- - ★★☆☆☆ (3-4 points): General industry trends, small events, routine permits/reports that are good to know. No direct action needed but helpful for trend awareness.
1124
- - ★☆☆☆☆ (1-2 points): Simple information sharing or repetitive news. Just for reference.
1125
-
1126
- Copyright Protection & Fact-Checking Principles:
1127
- - Extract only factual information from sources, completely exclude creative expressions
1128
- - When constructing new sentences from extracted facts, do not follow source structure
1129
- - Review for remaining source expressions after writing and modify to dry style
1130
- - Do not present content not specified in provided materials as fact
1131
- - Analysis and insights must be data-based; avoid baseless predictions or claims
1132
- - If information is uncertain or requires speculation, clearly use phrases like "is estimated" or "may be possible"
1133
-
1134
- Output Format & Requirements:
1135
- 1. Language: ${this.options.content.outputLanguage}
1136
-
1137
- 2. Start: ${this.options.content.freeFormIntro ? 'Begin directly with the Overall Briefing section (no separate opening heading or greeting).' : `Specify date (${this.dateService.getPublicationDisplayDateString()}) and begin with neutral, objective greeting. Briefly introduce key factual information to be covered in today's newsletter.`}
1138
-
1139
- 3. Overall Briefing: Before the main listing, create a briefing section conveying objective facts about today's news${this.options.content.freeFormIntro ? `. Structure: Start with a Heading 2 (##) briefing section heading in the format "## 📮 ${this.dateService.getPublicationDisplayDateString()} [Briefing/Summary word in output language]" (e.g., "## 📮 2월 6일 브리핑" for Korean, "## 📮 Feb 6 Briefing" for English) — do NOT include domain or field names in the heading. Immediately follow with a brief paragraph introducing key factual information to be covered in today's newsletter, then include the following bullet points:` : ' in these aspects:'}
1140
- - Key Trends: Explain major patterns or trends found in this news based on data. Ex: 'Over 00% of today's news relates to 00'.
1141
- - Immediate Impact: Emphasize most important changes or decisions affecting industry immediately, specifically mentioning which fields will be most impacted.
1142
-
1143
- 4. Category Classification & Content Organization:
1144
- - Group news by logical categories based on related tags and content (e.g., Policy/Regulation, Budget/Support, Research/Development, Products/Services, Operations/Process, Recruitment/Events) rather than just listing by importance.${this.options.content.freeFormIntro ? '\n - Use Heading 2 (##) for each category heading (same level as the briefing heading). Do NOT use Heading 3 (###) for categories.' : ''}
1145
- - Use appropriate emoticons for each category for visual distinction.
1146
- - Sort by importance within categories, making high-importance items more prominent.
1147
- - Add short paragraph at category start summarizing overall trends or changes in that area, specifying important points and areas to focus on.
1148
- - Group similar news together for joint analysis when multiple related items exist.
1149
- - When content is essentially identical (e.g., same job posting, event notice, announcement) from different sources, integrate around most detailed and accurate information without duplication.
1150
- - Use tables when helpful to show commonalities and differences between multiple items at a glance.
1151
- - Always provide links in [original title](URL) format whenever article titles or content are mentioned. Do not write as general text like "View" or "Article" or numbered references like [Post3](URL).
1152
-
1153
- 5. Detailed Content Writing Guidelines (Importance-Based Length Control):
1154
- **ABSOLUTE RULE: The writing length limits below are MAXIMUM constraints. DO NOT EXCEED these limits under any circumstances.**
1155
-
1156
- - **Full Detail Allowed: 9-10 points**
1157
- - Key Facts: 1-2 sentences in **bold** with source link [original title](URL).
1158
- - Related Targets & Scope: Bullet points for different target groups.
1159
- - Important Dates & Procedures: Deadlines, methods, required documents by step.
1160
- - Related Facts: Budget/scale/participants/scope as factual data.
1161
- - Use tables when comparing multiple items.
1162
-
1163
- - **ABSOLUTE MAXIMUM 3 SENTENCES: 6-8 points**
1164
- - Format: ONE sentence with **bold** key fact + [original title](URL) link. OPTIONALLY add ONE more sentence with critical detail (deadline/budget/target). NEVER EXCEED 3 SENTENCES TOTAL.
1165
- - DO NOT write bullet points, DO NOT write multiple paragraphs, DO NOT add subsections.
1166
-
1167
- - **ABSOLUTE MAXIMUM 1 SENTENCE: 1-5 points**
1168
- - Format: ONE single sentence with core fact + [original title](URL) link. PERIOD. NO ADDITIONAL SENTENCES.
1169
- - Multiple low-priority items can be grouped into a single bullet list.
1170
-
1171
- **VIOLATION WARNING: If you write more than the maximum sentences allowed, the output will be rejected and you must regenerate.**
1172
-
1173
- - **Structured Lists (Permits/Reports/Notices):** Create tables listing every item in individual rows without abbreviation, regardless of importance score.
1174
- - Use professional but friendly tone that's easy to understand. (Ex: Use "is notable" instead of "is", "is recommended" instead of "must", "needs to" etc.)
1175
- - Can use blockquotes to highlight expert comments or particularly emphasized insights.
1176
-
1177
- 6. Closing: Write objective closing including:
1178
- - Brief summary of key factual information covered today.
1179
- - Objectively list ongoing important schedules or imminent deadlines.
1180
- - Maintain neutral and objective tone.
1181
- - Do not write preview or anticipatory messages about next newsletter.
1182
- - Do not include contact information for inquiries.
1183
-
1184
- 7. Title Writing Guidelines:${this.options.content.titleContext
1185
- ? `\n - **Required title keyword**: "${this.options.content.titleContext}". This phrase MUST appear in the title. Combine it with key context from today's newsletter content to form a natural, complete title.
1186
- - Keep title length 20-70 characters.
1187
- - Use neutral and objective terms in title (e.g., 'announced', 'implementing', 'deadline approaching').
1188
- - Write title clearly and factually to maintain professionalism and credibility.`
1189
- : `
1190
- - Title should objectively convey core facts of 1-2 most important news items today.
1191
- - Write with key facts rather than simple "Newsletter", more effective with specific figures or schedules.
1192
- - Use neutral and objective terms in title (e.g., 'announced', 'implementing', 'deadline approaching').
1193
- - Keep title length 20-50 characters and can include 1-2 relevant emoticons.
1194
- - Place most important key facts at beginning of title.
1195
- - Write title clearly and factually to maintain professionalism and credibility.`}
1196
-
1197
- 8. Additional Requirements:
1198
- - Comprehensively analyze posts to create email containing most important information for ${this.expertFields.join(', ')} field experts.
1199
- ${this.options.content.freeFormIntro ? '' : `- Naturally include date at beginning in the format: "${this.dateService.getPublicationDisplayDateString()} ${this.expertFields.join(', ')} [News Term]". Replace [News Term] with the word for "News" appropriate for the output language (e.g., "News" for English, "소식" for Korean). Declare this part as \`Heading 1\`(#).\n `}- Write body in markdown format, effectively using headings(#, ##, ###), bold(**), italics(_), bullet points(-, *) etc. to improve readability.
1200
- - Group related news to provide broader context, and mention development status if there's continuity with content covered in previous issues.
1201
- - **Source citation is most important for ensuring credibility.** Must provide links in [original title](URL) format using source's title. Do not write as "View", "Article", "[Post3](URL)" format.
1202
- - Specify source whenever article titles or content are quoted in newsletter, ensure all information is provided with links.
1203
- - Discover connections and patterns between news items to provide integrated insights rather than simple listing, and provide data-based insightful analysis.
1204
- - Structure entire content so experts can quickly scan and grasp key information, design so busy experts can understand most important content within 2-3 minutes.
1205
- - Including simple small data analysis (e.g., "00% of this news budget-related", "30% increase in 00-related news vs last week") adds more valuable insight where possible.
1206
- ${this.subscribePageUrl ? `- Add \`${this.subscribePageUrl}\`(Subscribe to ${this.newsletterBrandName}) page access link button at appropriate attention-worthy spot for natural recommendation to others.` : ''}`;
1207
- }
1208
- get userPrompt() {
1209
- if (this.promptBuilder?.user) {
1210
- return this.promptBuilder.user(this.promptContext);
1211
- }
1212
- return `Below is the complete list of newly collected ${this.expertFields.join(', ')} related news:
1213
-
1214
- ${this.targetArticles
1215
- .map((post, index) => `## Post ${index + 1}
1216
- **Title:** ${post.title}
1217
- **Content:** ${post.detailContent}
1218
- **Importance:** ${post.importanceScore}/10
1219
- **Tags:** ${[post.tag1, post.tag2, post.tag3].filter(Boolean).join(', ')}
1220
- **Content Type:** ${post.contentType}
1221
- **URL:** ${post.url}${post.publishedDate ? `\n**Published Date:** ${post.publishedDate}` : ''}
1222
- ${post.imageContextByLlm ? `**Image Analysis (supplementary context only — do not cite specific details from this in the newsletter):** ${post.imageContextByLlm}` : ''}
1223
- `)
1224
- .join('\n\n')}
1225
-
1226
-
1227
- ---
1228
- **Comprehensive Analysis and Daily Newsletter Generation Request:**
1229
- **Newsletter Publication Date:** ${this.dateService.getPublicationISODateString()} (${this.dateService.getPublicationDisplayDateString()})
1230
-
1231
- Based on all post information provided above, please generate a ${this.expertFields.join(', ')} trends newsletter for ${this.dateService.getPublicationDisplayDateString()}. Please note the following:
1232
-
1233
- 0. **TEMPORAL VALIDITY (HIGHEST PRIORITY):** Before anything else, apply the Temporal Validity HARD RULE from the system prompt. Any post whose deadline/event has passed relative to the Newsletter Publication Date above MUST be excluded from the newsletter body. Do not reference excluded posts anywhere.
1234
-
1235
- 1. **STRICT LENGTH CONTROL BY IMPORTANCE SCORE:**
1236
- - 9-10 points: Full detailed coverage allowed (Key Facts + Targets + Dates + Related Facts)
1237
- - 6-8 points: MAXIMUM 3 SENTENCES ONLY. Do not write detailed analysis, subsections, or bullet points.
1238
- - 1-5 points: MAXIMUM 1 SENTENCE ONLY. Just core fact + link.
1239
-
1240
- 2. Prioritize high importance items (9-10 points) first and structure information by importance and topic.
1241
- 3. Instead of simply listing news items, group similar topics to strengthen information connectivity. For duplicate content from different sources (e.g., same job posting, same event notice), minimize redundancy by mentioning once or grouping around the most detailed content.
1242
- 4. Sort by highest importance within categories and analyze category trends and patterns to assess industry-wide impact.
1243
- 5. Include 1-2 most important news items in the title and use appropriate emoticons to enhance visual readability.
1244
- 6. Structure the final output so experts can quickly grasp key information and provide insights that aid practical decision-making.
1245
- 7. Source citation is crucial for credibility. Whenever mentioning any news, always provide links in [original title](URL) format. Do not use formats like "View Details" or "Post3". Content must always be accompanied by source links.
1246
- 8. Never create content not present in the provided materials. All analysis and insights must be based strictly on the provided post materials, without adding arbitrary information or presenting it as fact.
1247
- 9. Structured List (Permits/Reports/Notices etc.) Table Formatting Principles (No Omissions/Abbreviations):
1248
- - Do not bundle any items in this category; never abbreviate with "... and n more", "others", etc.
1249
- - List all items in tables with one item per row. Do not shorten tables regardless of length.
1250
- - Each row must include [original title](URL) and when available, list the following fields in order: Organization (or Publisher) | Region/Basin | Number/ID (Permit/Receipt/Report number etc.) | Date (Post/Issue/Permit date as shown in source).
1251
- - Mark missing values as "—" rather than leaving blank or omitting columns.
1252
- - Maintain table format without using bullet/number lists.
1253
-
1254
- Please follow the roles and output format defined in the system prompt (friendly introduction, overall briefing, category classification, in-depth analysis, polite closing, etc.).`;
1255
- }
1256
- };
1257
- function addNum(a, b) {
1258
- if (a == null && b == null)
1259
- return undefined;
1260
- return (a ?? 0) + (b ?? 0);
1261
- }
1262
- function addUsage(a, b) {
1263
- return {
1264
- inputTokens: addNum(a.inputTokens, b.inputTokens),
1265
- inputTokenDetails: {
1266
- noCacheTokens: addNum(a.inputTokenDetails?.noCacheTokens, b.inputTokenDetails?.noCacheTokens),
1267
- cacheReadTokens: addNum(a.inputTokenDetails?.cacheReadTokens, b.inputTokenDetails?.cacheReadTokens),
1268
- cacheWriteTokens: addNum(a.inputTokenDetails?.cacheWriteTokens, b.inputTokenDetails?.cacheWriteTokens),
1269
- },
1270
- outputTokens: addNum(a.outputTokens, b.outputTokens),
1271
- outputTokenDetails: {
1272
- textTokens: addNum(a.outputTokenDetails?.textTokens, b.outputTokenDetails?.textTokens),
1273
- reasoningTokens: addNum(a.outputTokenDetails?.reasoningTokens, b.outputTokenDetails?.reasoningTokens),
1274
- },
1275
- totalTokens: addNum(a.totalTokens, b.totalTokens),
1276
- };
1277
- }
1278
-
1279
- async function inlineCss(html) {
1280
- const { default: juice } = await import('juice');
1281
- return juice(html);
1282
- }
1283
- class ContentGenerateChain extends Chain {
1284
- dateService;
1285
- minimumArticleCountForIssue;
1286
- priorityArticleScoreThreshold;
1287
- htmlTemplate;
1288
- promptProvider;
1289
- constructor(config) {
1290
- super(config);
1291
- this.dateService = config.dateService;
1292
- this.promptProvider = config.promptProvider;
1293
- this.minimumArticleCountForIssue =
1294
- config.provider.publicationCriteria?.minimumArticleCountForIssue ?? 5;
1295
- this.priorityArticleScoreThreshold =
1296
- config.provider.publicationCriteria?.priorityArticleScoreThreshold ?? 8;
1297
- this.htmlTemplate = {
1298
- html: config.provider.htmlTemplate.html,
1299
- markers: {
1300
- title: config.provider.htmlTemplate.markers?.title ?? 'title',
1301
- content: config.provider.htmlTemplate.markers?.content ?? 'content',
1302
- },
1303
- };
1304
- }
1305
- get chain() {
1306
- return runnables.RunnablePassthrough.assign({
1307
- candidateArticles: () => this.fetchArticleCandidates(),
1308
- })
1309
- .pipe(runnables.RunnablePassthrough.assign({
1310
- generatedCoreContent: ({ candidateArticles }) => this.generateCoreContent(candidateArticles),
1311
- candidateArticles: ({ candidateArticles }) => candidateArticles,
1312
- }))
1313
- .pipe(runnables.RunnablePassthrough.assign({
1314
- html: ({ generatedCoreContent }) => this.renderHtml(generatedCoreContent),
1315
- generatedCoreContent: ({ generatedCoreContent }) => generatedCoreContent,
1316
- candidateArticles: ({ candidateArticles }) => candidateArticles,
1317
- }))
1318
- .pipe(runnables.RunnablePassthrough.assign({
1319
- newsletterId: ({ html, generatedCoreContent, candidateArticles }) => this.createNewsletter(html, generatedCoreContent, candidateArticles),
1320
- }))
1321
- .withRetry({ stopAfterAttempt: this.options.chain.stopAfterAttempt });
1322
- }
1323
- async fetchArticleCandidates() {
1324
- return this.executeWithLogging({
1325
- event: 'generate.content.articles.fetch',
1326
- level: 'debug',
1327
- doneFields: (items) => ({ count: items.length }),
1328
- }, async () => {
1329
- return await this.provider.fetchArticleCandidates();
1330
- });
1331
- }
1332
- async generateCoreContent(candidateArticles) {
1333
- return this.executeWithLogging({
1334
- event: 'generate.content.core.generate',
1335
- level: 'info',
1336
- startFields: { count: candidateArticles.length },
1337
- doneFields: (result) => ({ title: result?.title }),
1338
- }, async () => {
1339
- if (candidateArticles.length === 0) {
1340
- this.logger.debug({
1341
- event: 'generate.content.core.generate.noarticle',
1342
- taskId: this.taskId,
1343
- });
1344
- return null;
1345
- }
1346
- const hasHighImportancePost = candidateArticles.some(({ importanceScore }) => importanceScore >= this.priorityArticleScoreThreshold);
1347
- if (candidateArticles.length <= this.minimumArticleCountForIssue &&
1348
- !hasHighImportancePost) {
1349
- this.logger.debug({
1350
- event: 'generate.content.core.generate.criteria',
1351
- taskId: this.taskId,
1352
- data: {
1353
- count: candidateArticles.length,
1354
- hasHighImportancePost,
1355
- },
1356
- });
1357
- return null;
1358
- }
1359
- const generateNewsletter = new GenerateNewsletter$1({
1360
- model: this.provider.model,
1361
- maxOutputTokens: this.provider.maxOutputTokens,
1362
- temperature: this.provider.temperature,
1363
- topP: this.provider.topP,
1364
- topK: this.provider.topK,
1365
- presencePenalty: this.provider.presencePenalty,
1366
- frequencyPenalty: this.provider.frequencyPenalty,
1367
- logger: this.logger,
1368
- taskId: this.taskId,
1369
- targetArticles: candidateArticles,
1370
- options: esToolkit.pick(this.options, ['content', 'llm']),
1371
- loggingExecutor: new LoggingExecutor(this.logger, this.taskId),
1372
- subscribePageUrl: this.provider.subscribePageUrl,
1373
- newsletterBrandName: this.provider.newsletterBrandName,
1374
- dateService: this.dateService,
1375
- promptBuilder: this.promptProvider?.generateNewsletter,
1376
- });
1377
- const { result } = await generateNewsletter.execute();
1378
- return result;
1379
- });
1380
- }
1381
- async renderHtml(coreContent) {
1382
- return this.executeWithLogging({
1383
- event: 'generate.content.html.render',
1384
- level: 'debug',
1385
- startFields: { coreContent },
1386
- doneFields: (html) => ({ html }),
1387
- }, async () => {
1388
- if (!coreContent) {
1389
- return null;
1390
- }
1391
- return this.htmlTemplate.html
1392
- .replaceAll(`{{${this.htmlTemplate.markers.title}}}`, coreContent.title)
1393
- .replaceAll(`{{${this.htmlTemplate.markers.content}}}`, safeMarkdown2Html(ensureHrBeforeH2(coreContent.content), {
1394
- window: new jsdom.JSDOM('').window,
1395
- linkTargetBlank: true,
1396
- fixMalformedUrls: true,
1397
- fixBoldSyntax: true,
1398
- convertStrikethrough: true,
1399
- }));
1400
- });
1401
- }
1402
- async createNewsletter(html, coreContent, candidateArticles) {
1403
- return this.executeWithLogging({
1404
- event: 'generate.content.newsletter.create',
1405
- level: 'debug',
1406
- startFields: { html, count: candidateArticles.length },
1407
- doneFields: (id) => ({ id }),
1408
- }, async () => {
1409
- if (!html || !coreContent) {
1410
- return null;
1411
- }
1412
- const { id } = await this.provider.saveNewsletter({
1413
- newsletter: {
1414
- ...coreContent,
1415
- htmlBody: await inlineCss(html),
1416
- issueOrder: this.provider.issueOrder,
1417
- date: this.dateService.getPublicationISODateString(),
1418
- },
1419
- usedArticles: candidateArticles,
1420
- });
1421
- return id;
1422
- });
1423
- }
1424
- }
1425
-
1426
- // User-Agent list used by real browsers
1427
- const USER_AGENTS = [
1428
- // Windows - Chrome, Edge, Firefox
1429
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
1430
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0',
1431
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0',
1432
- // macOS - Chrome, Safari, Firefox
1433
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
1434
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
1435
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0',
1436
- // Linux - Chrome, Firefox
1437
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
1438
- 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0',
1439
- // Additional common combinations
1440
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
1441
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
1442
- ];
1443
- // Pick a random User-Agent
1444
- const getRandomUserAgent = () => USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
1445
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1446
- const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
1447
- function parseRetryAfter(header) {
1448
- if (!header)
1449
- return null;
1450
- // Seconds value
1451
- const seconds = Number(header);
1452
- if (!Number.isNaN(seconds))
1453
- return clamp(seconds * 1000, 0, 60_000);
1454
- // HTTP date format
1455
- const date = new Date(header);
1456
- const diff = date.getTime() - Date.now();
1457
- if (Number.isFinite(diff) && diff > 0)
1458
- return clamp(diff, 0, 60_000);
1459
- return null;
1460
- }
1461
- function shouldRetry(status, error) {
1462
- if (status === 429)
1463
- return true; // Too Many Requests (429)
1464
- if (status && status >= 500)
1465
- return true; // 5xx server error
1466
- if (status && status >= 400 && status < 500)
1467
- return false; // Fatal client error
1468
- // Network error or aborted
1469
- if (error instanceof Error) {
1470
- const msg = error.message.toLowerCase();
1471
- if (msg.includes('aborted') ||
1472
- msg.includes('timeout') ||
1473
- msg.includes('network') ||
1474
- msg.includes('fetch')) {
1475
- return true;
1476
- }
1477
- }
1478
- return false;
1479
- }
1480
- async function getHtmlFromUrl(logger, url, referer = 'https://www.google.com/', customFetch) {
1481
- const maxRetries = 5;
1482
- const baseTimeoutMs = 10_000; // Base 10s, increases per attempt
1483
- let lastError = null;
1484
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
1485
- const controller = new AbortController();
1486
- const timeoutMs = clamp(baseTimeoutMs * Math.pow(1.3, attempt - 1), 5_000, 30_000);
1487
- const timeout = setTimeout(() => controller.abort(`timeout after ${timeoutMs}ms`), timeoutMs);
1488
- try {
1489
- const startedAt = Date.now();
1490
- const response = await (customFetch ?? fetch)(url, {
1491
- // mode: 'cors' // Not applicable in Node, left here for behavioral parity with browsers
1492
- redirect: 'follow',
1493
- // @ts-expect-error Undici/Fetch in Node may allow duplex; safe to ignore
1494
- duplex: 'half',
1495
- signal: controller.signal,
1496
- headers: {
1497
- 'User-Agent': getRandomUserAgent(), // Randomize User-Agent
1498
- Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
1499
- 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
1500
- Referer: referer, // Include previous page information
1501
- Connection: 'keep-alive',
1502
- // Compression is handled automatically by undici/node-fetch, no need to set Accept-Encoding explicitly
1503
- },
1504
- });
1505
- clearTimeout(timeout);
1506
- const duration = Date.now() - startedAt;
1507
- const status = response.status;
1508
- if (!response.ok) {
1509
- const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'));
1510
- const canRetry = shouldRetry(status, null);
1511
- logger.debug({
1512
- event: 'fetch.error',
1513
- data: { url, status, attempt, canRetry, duration, retryAfterMs },
1514
- });
1515
- if (!canRetry || attempt === maxRetries) {
1516
- const msg = `Request failed (status=${status}) - ${url}`;
1517
- lastError = new Error(msg);
1518
- break;
1519
- }
1520
- const backoff = Math.pow(2, attempt - 1) * 1000 + Math.random() * 1000;
1521
- const delay = retryAfterMs != null ? Math.max(retryAfterMs, backoff) : backoff;
1522
- await sleep(delay);
1523
- continue;
1524
- }
1525
- const contentType = response.headers.get('content-type') || '';
1526
- if (!contentType.toLowerCase().includes('text/html')) {
1527
- // If not HTML, log a warning and continue (keep I/O compatibility)
1528
- logger.debug({
1529
- event: 'fetch.nonHtml',
1530
- data: { url, contentType, attempt },
1531
- });
1532
- }
1533
- const html = await response.text();
1534
- logger.debug({
1535
- event: 'fetch.success',
1536
- data: { url, status, attempt, duration, size: html.length },
1537
- });
1538
- // Short randomized sleep after success to reduce server load during crawling
1539
- const sleepTime = Math.random() * 500 + 250;
1540
- await sleep(sleepTime);
1541
- return html;
1542
- }
1543
- catch (error) {
1544
- clearTimeout(timeout);
1545
- const canRetry = shouldRetry(null, error);
1546
- lastError = error instanceof Error ? error : new Error(String(error));
1547
- logger.debug({
1548
- event: 'fetch.catch',
1549
- data: { url, attempt, canRetry, error: lastError.message },
1550
- });
1551
- if (!canRetry || attempt === maxRetries) {
1552
- logger.error({
1553
- event: 'fetch.failed',
1554
- data: { url, attempt, error: lastError.message },
1555
- });
1556
- throw lastError;
1557
- }
1558
- // Wait before the next attempt (exponential backoff + jitter)
1559
- const retryDelay = Math.pow(2, attempt - 1) * 1000 + Math.random() * 1000;
1560
- await sleep(retryDelay);
1561
- }
1562
- }
1563
- // Should not reach here; keep for type safety
1564
- throw lastError;
1565
- }
1566
-
1567
- const toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
1568
- class CrawlingChain extends Chain {
1569
- constructor(config) {
1570
- const provider = config.provider;
1571
- provider.maxConcurrency ??= 5;
1572
- super({ ...config, provider });
1573
- }
1574
- get chain() {
1575
- const mapping = this.provider.crawlingTargetGroups.reduce((result, group) => {
1576
- return {
1577
- ...result,
1578
- [group.name]: () => this.executeGroupPipeline(group),
1579
- };
1580
- }, {});
1581
- return runnables.RunnablePassthrough.assign(mapping);
1582
- }
1583
- async executeGroupPipeline(group) {
1584
- const groupLabel = group.name;
1585
- const chain = runnables.RunnablePassthrough.assign({
1586
- listPageHtml: ({ target }) => this.fetchListPageHtml(target),
1587
- })
1588
- .pipe({
1589
- parsedList: ({ target, listPageHtml }) => this.parseListPageHtml(target, listPageHtml),
1590
- target: ({ target }) => target,
1591
- })
1592
- .pipe({
1593
- list: ({ target, parsedList }) => this.dedupeListItems(target, parsedList),
1594
- target: ({ target }) => target,
1595
- })
1596
- .pipe({
1597
- detailFetchResult: ({ target, list }) => this.fetchDetailPagesHtml(target, list),
1598
- target: ({ target }) => target,
1599
- })
1600
- .pipe({
1601
- detailParseResult: ({ target, detailFetchResult }) => this.parseDetailPagesHtml(target, detailFetchResult.list, detailFetchResult.detailPagesHtmlWithPipelineId),
1602
- target: ({ target }) => target,
1603
- })
1604
- .pipe({
1605
- processedArticles: ({ target, detailParseResult }) => this.mergeParsedArticles(target, detailParseResult.list, detailParseResult.parsedDetails),
1606
- target: ({ target }) => target,
1607
- })
1608
- .pipe({
1609
- count: ({ target, processedArticles }) => this.saveArticles(group, target, processedArticles),
1610
- })
1611
- .withRetry({ stopAfterAttempt: this.options.chain.stopAfterAttempt });
1612
- return this.executeWithLogging({
1613
- event: 'crawl.group',
1614
- level: 'debug',
1615
- startFields: {
1616
- group: groupLabel,
1617
- targets: group.targets.length,
1618
- },
1619
- doneFields: (total) => ({ totalSaved: total }),
1620
- }, async () => {
1621
- const results = await chain.batch(group.targets.map((target) => ({ target })), {
1622
- maxConcurrency: this.provider.maxConcurrency,
1623
- });
1624
- return results.reduce((sum, result) => sum + result.count, 0);
1625
- });
1626
- }
1627
- async fetchListPageHtml(target) {
1628
- return this.executeWithLogging({
1629
- event: 'crawl.list.fetch',
1630
- level: 'debug',
1631
- startFields: { target: this.describeTarget(target) },
1632
- }, async () => {
1633
- try {
1634
- return await getHtmlFromUrl(this.logger, target.url, undefined, this.provider.customFetch);
1635
- }
1636
- catch (error) {
1637
- this.logger.error({
1638
- event: 'crawl.list.fetch.failed',
1639
- taskId: this.taskId,
1640
- data: {
1641
- target: this.describeTarget(target),
1642
- url: target.url,
1643
- error: toErrorMessage(error),
1644
- },
1645
- });
1646
- return '';
1647
- }
1648
- });
1649
- }
1650
- async parseListPageHtml(target, listPageHtml) {
1651
- return this.executeWithLogging({
1652
- event: 'crawl.list.parse',
1653
- level: 'debug',
1654
- startFields: {
1655
- target: this.describeTarget(target),
1656
- htmlLength: listPageHtml.length,
1657
- },
1658
- doneFields: (items) => ({ count: items.length }),
1659
- }, async () => {
1660
- if (listPageHtml.length === 0) {
1661
- return [];
1662
- }
1663
- try {
1664
- return (await target.parseList(listPageHtml)).map((item) => ({
1665
- ...item,
1666
- pipelineId: node_crypto.randomUUID(),
1667
- }));
1668
- }
1669
- catch (error) {
1670
- this.logger.error({
1671
- event: 'crawl.list.parse.failed',
1672
- taskId: this.taskId,
1673
- data: {
1674
- target: this.describeTarget(target),
1675
- error: toErrorMessage(error),
1676
- },
1677
- });
1678
- return [];
1679
- }
1680
- });
1681
- }
1682
- async dedupeListItems(target, parsedList) {
1683
- return this.executeWithLogging({
1684
- event: 'crawl.list.dedupe',
1685
- level: 'debug',
1686
- startFields: {
1687
- target: this.describeTarget(target),
1688
- inCount: parsedList.length,
1689
- },
1690
- doneFields: (deduped) => ({
1691
- outCount: deduped.length,
1692
- filtered: parsedList.length - deduped.length,
1693
- }),
1694
- }, async () => {
1695
- const existingArticles = await this.provider.fetchExistingArticlesByUrls(parsedList.map(({ detailUrl }) => detailUrl));
1696
- const existingUrlSet = new Set(existingArticles.map(({ detailUrl }) => detailUrl));
1697
- return parsedList.filter((item) => !existingUrlSet.has(item.detailUrl));
1698
- });
1699
- }
1700
- async fetchDetailPagesHtml(target, list) {
1701
- return this.executeWithLogging({
1702
- event: 'crawl.detail.fetch',
1703
- level: 'debug',
1704
- startFields: {
1705
- target: this.describeTarget(target),
1706
- count: list.length,
1707
- },
1708
- doneFields: (result) => ({
1709
- successCount: result.detailPagesHtmlWithPipelineId.length,
1710
- failedCount: result.failedCount,
1711
- }),
1712
- }, async () => {
1713
- const settled = await Promise.allSettled(list.map((data) => getHtmlFromUrl(this.logger, data.detailUrl, undefined, this.provider.customFetch)));
1714
- const detailPagesHtmlWithPipelineId = [];
1715
- const successList = [];
1716
- let failedCount = 0;
1717
- settled.forEach((result, index) => {
1718
- const item = list[index];
1719
- if (result.status === 'fulfilled') {
1720
- detailPagesHtmlWithPipelineId.push({
1721
- pipelineId: item.pipelineId,
1722
- html: result.value,
1723
- });
1724
- successList.push(item);
1725
- return;
1726
- }
1727
- failedCount += 1;
1728
- this.logger.error({
1729
- event: 'crawl.detail.fetch.failed',
1730
- taskId: this.taskId,
1731
- data: {
1732
- target: this.describeTarget(target),
1733
- detailUrl: item.detailUrl,
1734
- error: toErrorMessage(result.reason),
1735
- },
1736
- });
1737
- });
1738
- return {
1739
- list: successList,
1740
- detailPagesHtmlWithPipelineId,
1741
- failedCount,
1742
- };
1743
- });
1744
- }
1745
- async parseDetailPagesHtml(target, list, detailPagesHtmlWithPipelineId) {
1746
- return this.executeWithLogging({
1747
- event: 'crawl.detail.parse',
1748
- level: 'debug',
1749
- startFields: {
1750
- target: this.describeTarget(target),
1751
- count: detailPagesHtmlWithPipelineId.length,
1752
- },
1753
- doneFields: (result) => ({
1754
- successCount: result.parsedDetails.length,
1755
- failedCount: result.failedCount,
1756
- }),
1757
- }, async () => {
1758
- const listItemMap = new Map(list.map((item) => [item.pipelineId, item]));
1759
- const settled = await Promise.allSettled(detailPagesHtmlWithPipelineId.map(({ html }) => target.parseDetail(html)));
1760
- const parsedDetails = [];
1761
- const successList = [];
1762
- let failedCount = 0;
1763
- settled.forEach((result, index) => {
1764
- const htmlItem = detailPagesHtmlWithPipelineId[index];
1765
- const listItem = listItemMap.get(htmlItem.pipelineId);
1766
- if (result.status === 'fulfilled' && listItem) {
1767
- parsedDetails.push({
1768
- pipelineId: htmlItem.pipelineId,
1769
- ...result.value,
1770
- });
1771
- successList.push(listItem);
1772
- return;
1773
- }
1774
- failedCount += 1;
1775
- this.logger.error({
1776
- event: 'crawl.detail.parse.failed',
1777
- taskId: this.taskId,
1778
- data: {
1779
- target: this.describeTarget(target),
1780
- detailUrl: listItem?.detailUrl,
1781
- pipelineId: htmlItem.pipelineId,
1782
- error: result.status === 'rejected'
1783
- ? toErrorMessage(result.reason)
1784
- : 'Missing list item for parsed detail',
1785
- },
1786
- });
1787
- });
1788
- return {
1789
- list: successList,
1790
- parsedDetails,
1791
- failedCount,
1792
- };
1793
- });
1794
- }
1795
- // Although this is a synchronous method, using async wrapping to maintain consistency with the executeWithLogging interface
1796
- async mergeParsedArticles(target, list, parsedDetails) {
1797
- return this.executeWithLogging({
1798
- event: 'crawl.merge',
1799
- level: 'debug',
1800
- startFields: {
1801
- target: this.describeTarget(target),
1802
- listCount: list.length,
1803
- detailCount: parsedDetails.length,
1804
- },
1805
- doneFields: (merged) => ({ count: merged.length }),
1806
- }, async () => {
1807
- const listItemMap = new Map(list.map((item) => [item.pipelineId, item]));
1808
- const merged = parsedDetails.map((detail) => {
1809
- const listItem = listItemMap.get(detail.pipelineId);
1810
- if (!listItem) {
1811
- throw new Error(`No matching list item for detail with pipelineId: ${detail.pipelineId}`);
1812
- }
1813
- return {
1814
- ...esToolkit.omit(listItem, ['pipelineId']),
1815
- ...esToolkit.omit(detail, ['pipelineId']),
1816
- title: replaceAsciiQuotes(listItem.title),
1817
- ...(detail.detailContent != null && {
1818
- detailContent: replaceAsciiQuotes(detail.detailContent),
1819
- }),
1820
- };
1821
- });
1822
- return merged;
1823
- });
1824
- }
1825
- async saveArticles(group, target, processedArticles) {
1826
- const omittedGroup = esToolkit.omit(group, ['targets']);
1827
- return this.executeWithLogging({
1828
- event: 'crawl.save',
1829
- level: 'debug',
1830
- startFields: {
1831
- group: omittedGroup,
1832
- target: this.describeTarget(target),
1833
- count: processedArticles.length,
1834
- },
1835
- doneFields: (saved) => ({ saved }),
1836
- }, async () => {
1837
- return await this.provider.saveCrawledArticles(processedArticles, {
1838
- taskId: this.taskId,
1839
- targetGroup: omittedGroup,
1840
- target,
1841
- });
1842
- });
1843
- }
1844
- describeTarget(target) {
1845
- return {
1846
- name: target.name || 'unknown',
1847
- listUrl: target.url,
1848
- };
1849
- }
1850
- }
1851
-
1852
- /**
1853
- * Core class that orchestrates LLM-based newsletter generation.
1854
- * - Responsible for the flow: Crawling → Analysis → Content Generation → Save; external dependencies are injected via DI.
1855
- */
1856
- class GenerateNewsletter {
1857
- /** Internal fields provided via dependency injection */
1858
- dateService;
1859
- taskService;
1860
- crawlingProvider;
1861
- analysisProvider;
1862
- contentGenerateProvider;
1863
- logger;
1864
- options;
1865
- promptProvider;
1866
- previewNewsletterOptions;
1867
- /** Independent internal field **/
1868
- taskId = null;
1869
- /**
1870
- * Constructor
1871
- *
1872
- * @param config
1873
- * @example
1874
- * const generator = new GenerateNewsletter({
1875
- * outputLanguage: 'English',
1876
- * expertField: ['AI', 'Cloud'],
1877
- * dateService,
1878
- * taskService,
1879
- * tagProvider,
1880
- * crawlingProvider,
1881
- * analysisProvider,
1882
- * contentGenerateProvider,
1883
- * options: { llm: { maxRetries: 5 } },
1884
- * });
1885
- */
1886
- constructor(config) {
1887
- const defaultOptions = {
1888
- content: config.contentOptions,
1889
- llm: { maxRetries: 5 },
1890
- chain: { stopAfterAttempt: 3 },
1891
- };
1892
- this.dateService = config.dateService;
1893
- this.taskService = config.taskService;
1894
- this.crawlingProvider = config.crawlingProvider;
1895
- this.analysisProvider = config.analysisProvider;
1896
- this.contentGenerateProvider = config.contentGenerateProvider;
1897
- this.options = {
1898
- ...defaultOptions,
1899
- ...config.options,
1900
- llm: {
1901
- ...defaultOptions.llm,
1902
- ...config.options?.llm,
1903
- },
1904
- chain: {
1905
- ...defaultOptions.chain,
1906
- ...config.options?.chain,
1907
- },
1908
- };
1909
- this.promptProvider = config.promptProvider;
1910
- // Default logger (no-op)
1911
- this.logger = config.options?.logger ?? {
1912
- info: (_msg) => { },
1913
- debug: (_msg) => { },
1914
- error: (_msg) => { },
1915
- };
1916
- // Store preview newsletter options
1917
- this.previewNewsletterOptions = config.options?.previewNewsletter;
1918
- }
1919
- /**
1920
- * Execute the full newsletter generation pipeline.
1921
- */
1922
- async generate() {
1923
- const { newsletterId } = await this.executeWithTaskManagement(async () => {
1924
- const loggingExecutor = new LoggingExecutor(this.logger, this.taskId);
1925
- const crawlingChain = new CrawlingChain({
1926
- logger: this.logger,
1927
- taskId: this.taskId,
1928
- provider: this.crawlingProvider,
1929
- options: this.options,
1930
- loggingExecutor,
1931
- });
1932
- const analysisChain = new AnalysisChain({
1933
- logger: this.logger,
1934
- taskId: this.taskId,
1935
- provider: this.analysisProvider,
1936
- options: this.options,
1937
- loggingExecutor,
1938
- dateService: this.dateService,
1939
- promptProvider: this.promptProvider?.analysis,
1940
- });
1941
- const contentGenerateChain = new ContentGenerateChain({
1942
- logger: this.logger,
1943
- taskId: this.taskId,
1944
- provider: this.contentGenerateProvider,
1945
- options: this.options,
1946
- loggingExecutor,
1947
- dateService: this.dateService,
1948
- promptProvider: this.promptProvider?.contentGenerate,
1949
- });
1950
- const taskChain = runnables.RunnableSequence.from([
1951
- crawlingChain.chain,
1952
- analysisChain.chain,
1953
- contentGenerateChain.chain,
1954
- ]);
1955
- return await taskChain.invoke({});
1956
- });
1957
- this.logNewsletterResult(newsletterId);
1958
- await this.sendPreviewNewsletterIfConfigured(newsletterId);
1959
- return newsletterId;
1960
- }
1961
- /**
1962
- * Run the pipeline while managing the task lifecycle.
1963
- */
1964
- async executeWithTaskManagement(pipeline) {
1965
- await this.startTask();
1966
- const executor = new LoggingExecutor(this.logger, this.taskId);
1967
- try {
1968
- return await executor.executeWithLogging({
1969
- event: 'task',
1970
- level: 'info',
1971
- }, async () => {
1972
- return await pipeline();
1973
- });
1974
- }
1975
- finally {
1976
- await this.endTask();
1977
- }
1978
- }
1979
- logNewsletterResult(newsletterId) {
1980
- if (newsletterId === null) {
1981
- this.logger.info({
1982
- event: 'generate.result.skipped',
1983
- taskId: this.taskId,
1984
- data: { reason: 'publicationCriteria.notMet' },
1985
- });
1986
- return;
1987
- }
1988
- this.logger.info({
1989
- event: 'generate.result.created',
1990
- taskId: this.taskId,
1991
- data: { newsletterId },
1992
- });
1993
- }
1994
- async sendPreviewNewsletterIfConfigured(newsletterId) {
1995
- const preview = this.previewNewsletterOptions;
1996
- if (!preview) {
1997
- return;
1998
- }
1999
- if (newsletterId === null) {
2000
- this.logger.info({
2001
- event: 'generate.preview.skip',
2002
- taskId: this.taskId,
2003
- data: { reason: 'noNewsletterCreated' },
2004
- });
2005
- return;
2006
- }
2007
- try {
2008
- // Fetch newsletter entity for preview
2009
- const newsletter = await preview.fetchNewsletterForPreview();
2010
- // Compose email subject/html/text
2011
- const subject = `[Preview] ${newsletter.title}`;
2012
- const html = newsletter.htmlBody;
2013
- const text = `${newsletter.title}\n\nIssue #${newsletter.issueOrder} - ${newsletter.date}`;
2014
- await preview.emailService.send({
2015
- ...preview.emailMessage,
2016
- subject,
2017
- html,
2018
- text,
2019
- });
2020
- this.logger.info({
2021
- event: 'generate.preview.sent',
2022
- taskId: this.taskId,
2023
- data: {
2024
- newsletterId,
2025
- to: preview.emailMessage.to,
2026
- },
2027
- });
2028
- }
2029
- catch (err) {
2030
- this.logger.error({
2031
- event: 'generate.preview.error',
2032
- taskId: this.taskId,
2033
- data: { newsletterId },
2034
- });
2035
- this.logger.error(err);
2036
- }
2037
- }
2038
- async startTask() {
2039
- this.taskId = await this.taskService.start();
2040
- }
2041
- async endTask() {
2042
- await this.taskService.end();
2043
- }
2044
- }
2045
-
2046
- exports.GenerateNewsletter = GenerateNewsletter;
2047
- //# sourceMappingURL=index.cjs.map