@ttkit/daily-hot-api 0.1.2 → 0.1.4
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/.env.example +26 -0
- package/README.md +26 -0
- package/dist/app.module.js +2 -0
- package/dist/app.module.js.map +1 -1
- package/dist/cli/args.d.ts +3 -1
- package/dist/cli/args.js +25 -2
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/commands/logs.js +5 -0
- package/dist/cli/commands/logs.js.map +1 -1
- package/dist/cli/commands/restart.js +14 -3
- package/dist/cli/commands/restart.js.map +1 -1
- package/dist/cli/commands/start.js +17 -9
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/commands/status.js +5 -4
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/stop.js +1 -1
- package/dist/cli/commands/stop.js.map +1 -1
- package/dist/cli/env.d.ts +1 -0
- package/dist/cli/env.js +12 -0
- package/dist/cli/env.js.map +1 -1
- package/dist/cli/health.d.ts +1 -1
- package/dist/cli/health.js +32 -17
- package/dist/cli/health.js.map +1 -1
- package/dist/cli/help.js +14 -2
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/log-file.d.ts +4 -0
- package/dist/cli/log-file.js +54 -5
- package/dist/cli/log-file.js.map +1 -1
- package/dist/cli/paths.d.ts +1 -0
- package/dist/cli/paths.js +2 -1
- package/dist/cli/paths.js.map +1 -1
- package/dist/cli/run-state.d.ts +6 -1
- package/dist/cli/run-state.js +18 -6
- package/dist/cli/run-state.js.map +1 -1
- package/dist/config/validation.schema.d.ts +54 -0
- package/dist/config/validation.schema.js +75 -0
- package/dist/config/validation.schema.js.map +1 -1
- package/dist/daily-brief/daily-brief.module.js +1 -1
- package/dist/daily-brief/daily-brief.module.js.map +1 -1
- package/dist/daily-brief/daily-brief.service.js +1 -1
- package/dist/daily-brief/daily-brief.service.js.map +1 -1
- package/dist/database/database.module.js +15 -2
- package/dist/database/database.module.js.map +1 -1
- package/dist/database/repositories/daily-brief.repository.js +11 -1
- package/dist/database/repositories/daily-brief.repository.js.map +1 -1
- package/dist/database/repositories/hot-item.repository.d.ts +1 -1
- package/dist/database/repositories/hot-item.repository.js +7 -4
- package/dist/database/repositories/hot-item.repository.js.map +1 -1
- package/dist/database/repositories/news-brief.repository.d.ts +81 -0
- package/dist/database/repositories/news-brief.repository.js +208 -0
- package/dist/database/repositories/news-brief.repository.js.map +1 -0
- package/dist/database/schemas/news-brief.schema.d.ts +48 -0
- package/dist/database/schemas/news-brief.schema.js +130 -0
- package/dist/database/schemas/news-brief.schema.js.map +1 -0
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/news-brief/categories/news-category.registry.d.ts +5 -0
- package/dist/news-brief/categories/news-category.registry.js +106 -0
- package/dist/news-brief/categories/news-category.registry.js.map +1 -0
- package/dist/news-brief/clients/news-brief-ai.client.d.ts +45 -0
- package/dist/news-brief/clients/news-brief-ai.client.js +314 -0
- package/dist/news-brief/clients/news-brief-ai.client.js.map +1 -0
- package/dist/news-brief/clients/news-brief-search.service.d.ts +12 -0
- package/dist/news-brief/clients/news-brief-search.service.js +57 -0
- package/dist/news-brief/clients/news-brief-search.service.js.map +1 -0
- package/dist/news-brief/dto/generate-news-brief.dto.d.ts +7 -0
- package/dist/news-brief/dto/generate-news-brief.dto.js +45 -0
- package/dist/news-brief/dto/generate-news-brief.dto.js.map +1 -0
- package/dist/news-brief/dto/list-news-briefs-query.dto.d.ts +7 -0
- package/dist/news-brief/dto/list-news-briefs-query.dto.js +59 -0
- package/dist/news-brief/dto/list-news-briefs-query.dto.js.map +1 -0
- package/dist/news-brief/dto/news-brief-date-param.dto.d.ts +3 -0
- package/dist/news-brief/dto/news-brief-date-param.dto.js +22 -0
- package/dist/news-brief/dto/news-brief-date-param.dto.js.map +1 -0
- package/dist/news-brief/interfaces/news-brief.interface.d.ts +202 -0
- package/dist/news-brief/interfaces/news-brief.interface.js +3 -0
- package/dist/news-brief/interfaces/news-brief.interface.js.map +1 -0
- package/dist/news-brief/news-brief.config.d.ts +33 -0
- package/dist/news-brief/news-brief.config.js +118 -0
- package/dist/news-brief/news-brief.config.js.map +1 -0
- package/dist/news-brief/news-brief.controller.d.ts +56 -0
- package/dist/news-brief/news-brief.controller.js +185 -0
- package/dist/news-brief/news-brief.controller.js.map +1 -0
- package/dist/news-brief/news-brief.module.d.ts +2 -0
- package/dist/news-brief/news-brief.module.js +47 -0
- package/dist/news-brief/news-brief.module.js.map +1 -0
- package/dist/news-brief/news-brief.scheduler.d.ts +34 -0
- package/dist/news-brief/news-brief.scheduler.js +143 -0
- package/dist/news-brief/news-brief.scheduler.js.map +1 -0
- package/dist/news-brief/news-brief.service.d.ts +60 -0
- package/dist/news-brief/news-brief.service.js +676 -0
- package/dist/news-brief/news-brief.service.js.map +1 -0
- package/package.json +3 -1
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var NewsBriefService_1;
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.NewsBriefService = void 0;
|
|
14
|
+
const common_1 = require("@nestjs/common");
|
|
15
|
+
const crypto_1 = require("crypto");
|
|
16
|
+
const cache_service_1 = require("../cache/cache.service");
|
|
17
|
+
const hot_lists_service_1 = require("../host-lists/hot-lists.service");
|
|
18
|
+
const hot_item_repository_1 = require("../database/repositories/hot-item.repository");
|
|
19
|
+
const news_brief_repository_1 = require("../database/repositories/news-brief.repository");
|
|
20
|
+
const fetch_hot_data_task_1 = require("../scheduler/tasks/fetch-hot-data.task");
|
|
21
|
+
const news_category_registry_1 = require("./categories/news-category.registry");
|
|
22
|
+
const news_brief_ai_client_1 = require("./clients/news-brief-ai.client");
|
|
23
|
+
const news_brief_search_service_1 = require("./clients/news-brief-search.service");
|
|
24
|
+
const news_brief_config_1 = require("./news-brief.config");
|
|
25
|
+
const brief_date_1 = require("../daily-brief/utils/brief-date");
|
|
26
|
+
const concurrency_1 = require("../daily-brief/utils/concurrency");
|
|
27
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
28
|
+
const PERIOD_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
29
|
+
const SEARCH_CACHE_PREFIX = 'news-brief:search:v1:';
|
|
30
|
+
const SEARCH_CACHE_TTL_SECONDS = 3600;
|
|
31
|
+
let NewsBriefService = NewsBriefService_1 = class NewsBriefService {
|
|
32
|
+
constructor(config, newsBriefRepository, hotItemRepository, fetchHotDataTask, hotListsService, aiClient, searchService, cacheService) {
|
|
33
|
+
this.config = config;
|
|
34
|
+
this.newsBriefRepository = newsBriefRepository;
|
|
35
|
+
this.hotItemRepository = hotItemRepository;
|
|
36
|
+
this.fetchHotDataTask = fetchHotDataTask;
|
|
37
|
+
this.hotListsService = hotListsService;
|
|
38
|
+
this.aiClient = aiClient;
|
|
39
|
+
this.searchService = searchService;
|
|
40
|
+
this.cacheService = cacheService;
|
|
41
|
+
this.logger = new common_1.Logger(NewsBriefService_1.name);
|
|
42
|
+
}
|
|
43
|
+
async generate(options = {}) {
|
|
44
|
+
const period = this.normalizePeriod(options.period);
|
|
45
|
+
const briefDate = this.resolveBriefDate(options.date);
|
|
46
|
+
const profiles = this.resolveGenerateProfiles(options.category);
|
|
47
|
+
const sourcesOverride = this.resolveSourcesOverride(options);
|
|
48
|
+
const run = {
|
|
49
|
+
freshItems: new Map(),
|
|
50
|
+
sourceNames: this.buildSourceNameMap(),
|
|
51
|
+
};
|
|
52
|
+
const results = await (0, concurrency_1.mapWithConcurrency)(profiles, this.config.categoryConcurrency, (profile) => this.generateForCategory(profile, {
|
|
53
|
+
briefDate,
|
|
54
|
+
period,
|
|
55
|
+
force: Boolean(options.force),
|
|
56
|
+
sourcesOverride,
|
|
57
|
+
run,
|
|
58
|
+
}).catch((error) => {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
this.logger.error(`[NewsBrief] ${profile.code} generation aborted: ${message}`);
|
|
61
|
+
return {
|
|
62
|
+
category: profile.code,
|
|
63
|
+
categoryName: profile.name,
|
|
64
|
+
status: 'failed',
|
|
65
|
+
error: message,
|
|
66
|
+
};
|
|
67
|
+
}));
|
|
68
|
+
return { briefDate, period, results };
|
|
69
|
+
}
|
|
70
|
+
async findLatest(category, period, includeDebug = false) {
|
|
71
|
+
const profile = this.requireKnownCategory(category);
|
|
72
|
+
const brief = await this.newsBriefRepository.findLatest(profile.code, this.normalizePeriod(period), includeDebug);
|
|
73
|
+
return brief ? this.toResponse(brief, includeDebug) : null;
|
|
74
|
+
}
|
|
75
|
+
async findByDate(date, category, period, includeDebug = false) {
|
|
76
|
+
const profile = this.requireKnownCategory(category);
|
|
77
|
+
const brief = await this.newsBriefRepository.findByDate(this.validateDate(date), this.normalizePeriod(period), profile.code, includeDebug);
|
|
78
|
+
return brief ? this.toResponse(brief, includeDebug) : null;
|
|
79
|
+
}
|
|
80
|
+
async findDigest(categories, date, period) {
|
|
81
|
+
const profiles = this.resolveDigestProfiles(categories);
|
|
82
|
+
const briefDate = date
|
|
83
|
+
? this.validateDate(date)
|
|
84
|
+
: (0, brief_date_1.formatBriefDate)(new Date(), this.config.timezone);
|
|
85
|
+
const normalizedPeriod = this.normalizePeriod(period);
|
|
86
|
+
const entries = await Promise.all(profiles.map(async (profile) => {
|
|
87
|
+
const brief = await this.newsBriefRepository.findByDate(briefDate, normalizedPeriod, profile.code);
|
|
88
|
+
return {
|
|
89
|
+
category: profile.code,
|
|
90
|
+
categoryName: profile.name,
|
|
91
|
+
status: brief ? brief.status : 'missing',
|
|
92
|
+
brief: brief ? this.toResponse(brief) : null,
|
|
93
|
+
};
|
|
94
|
+
}));
|
|
95
|
+
return { briefDate, categories: entries };
|
|
96
|
+
}
|
|
97
|
+
async list(options) {
|
|
98
|
+
const category = options.category
|
|
99
|
+
? this.requireKnownCategory(options.category).code
|
|
100
|
+
: undefined;
|
|
101
|
+
const period = this.normalizePeriod(options.period);
|
|
102
|
+
return this.newsBriefRepository.list({
|
|
103
|
+
page: options.page,
|
|
104
|
+
limit: options.limit,
|
|
105
|
+
category,
|
|
106
|
+
status: options.status,
|
|
107
|
+
period,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async deleteByDate(date, category, period) {
|
|
111
|
+
const briefDate = this.validateDate(date);
|
|
112
|
+
if (!category) {
|
|
113
|
+
throw new common_1.BadRequestException('category is required when deleting a news brief date');
|
|
114
|
+
}
|
|
115
|
+
const profile = this.requireKnownCategory(category);
|
|
116
|
+
const normalizedPeriod = this.normalizeOptionalPeriod(period);
|
|
117
|
+
const result = await this.newsBriefRepository.deleteByDate(briefDate, profile.code, normalizedPeriod);
|
|
118
|
+
return {
|
|
119
|
+
mode: 'date',
|
|
120
|
+
briefDate,
|
|
121
|
+
category: profile.code,
|
|
122
|
+
period: normalizedPeriod ?? null,
|
|
123
|
+
deletedCount: result.deletedCount ?? 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async deleteHistory(options) {
|
|
127
|
+
if (options.olderThan && options.beforeDate) {
|
|
128
|
+
throw new common_1.BadRequestException('Use either olderThan or beforeDate, not both');
|
|
129
|
+
}
|
|
130
|
+
const beforeDate = options.beforeDate
|
|
131
|
+
? this.validateDate(options.beforeDate)
|
|
132
|
+
: this.resolveOlderThanDate(options.olderThan);
|
|
133
|
+
const category = options.category
|
|
134
|
+
? this.requireKnownCategory(options.category).code
|
|
135
|
+
: undefined;
|
|
136
|
+
const result = await this.newsBriefRepository.deleteBeforeDate(beforeDate, category);
|
|
137
|
+
return {
|
|
138
|
+
mode: options.beforeDate ? 'beforeDate' : 'olderThan',
|
|
139
|
+
olderThan: options.olderThan || null,
|
|
140
|
+
beforeDate,
|
|
141
|
+
category: category ?? null,
|
|
142
|
+
deletedCount: result.deletedCount ?? 0,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
getConfig() {
|
|
146
|
+
return {
|
|
147
|
+
cronExpression: this.config.cronExpression,
|
|
148
|
+
timezone: this.config.timezone,
|
|
149
|
+
categories: this.config.categorySummaries,
|
|
150
|
+
lookbackHours: this.config.lookbackHours,
|
|
151
|
+
topItemsPerSource: this.config.topItemsPerSource,
|
|
152
|
+
maxCandidates: this.config.maxCandidates,
|
|
153
|
+
maxItemsPerCategory: this.config.maxItemsPerCategory,
|
|
154
|
+
categoryConcurrency: this.config.categoryConcurrency,
|
|
155
|
+
sourceConcurrency: this.config.sourceConcurrency,
|
|
156
|
+
generatingTimeoutMinutes: this.config.generatingTimeoutMinutes,
|
|
157
|
+
manualGenerateEnabled: this.config.manualGenerateEnabled,
|
|
158
|
+
model: this.aiClient.getModel(),
|
|
159
|
+
searchEnabled: this.config.searchEnabled,
|
|
160
|
+
searchProviders: this.config.searchProviders,
|
|
161
|
+
searchMaxQueries: this.config.searchMaxQueries,
|
|
162
|
+
searchQueryMaxChars: this.config.searchQueryMaxChars,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
getCategories() {
|
|
166
|
+
return this.config.categorySummaries;
|
|
167
|
+
}
|
|
168
|
+
async generateForCategory(profile, params) {
|
|
169
|
+
const sources = params.sourcesOverride ?? profile.sources;
|
|
170
|
+
if (!params.force) {
|
|
171
|
+
const existing = await this.newsBriefRepository.findByDate(params.briefDate, params.period, profile.code);
|
|
172
|
+
if (existing?.status === 'success') {
|
|
173
|
+
return {
|
|
174
|
+
category: profile.code,
|
|
175
|
+
categoryName: profile.name,
|
|
176
|
+
status: 'skipped',
|
|
177
|
+
reason: 'already_generated',
|
|
178
|
+
brief: this.toResponse(existing),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const generationId = (0, crypto_1.randomUUID)();
|
|
183
|
+
const timeoutMs = this.config.generatingTimeoutMinutes * 60_000;
|
|
184
|
+
const inputWindow = this.resolveInputWindow(params.briefDate, this.config.lookbackHours);
|
|
185
|
+
const claimed = await this.newsBriefRepository.claimGeneration({
|
|
186
|
+
briefDate: params.briefDate,
|
|
187
|
+
period: params.period,
|
|
188
|
+
category: profile.code,
|
|
189
|
+
categoryName: profile.name,
|
|
190
|
+
sources,
|
|
191
|
+
inputWindow,
|
|
192
|
+
model: this.aiClient.getModel(),
|
|
193
|
+
generationId,
|
|
194
|
+
staleBefore: new Date(Date.now() - timeoutMs),
|
|
195
|
+
});
|
|
196
|
+
if (!claimed) {
|
|
197
|
+
return {
|
|
198
|
+
category: profile.code,
|
|
199
|
+
categoryName: profile.name,
|
|
200
|
+
status: 'skipped',
|
|
201
|
+
reason: 'generation_in_progress',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const heartbeat = setInterval(() => {
|
|
205
|
+
void this.newsBriefRepository
|
|
206
|
+
.heartbeatGeneration(params.briefDate, params.period, profile.code, generationId)
|
|
207
|
+
.catch((error) => this.logger.warn('News brief heartbeat failed', error));
|
|
208
|
+
}, Math.min(60_000, Math.max(1_000, Math.floor(timeoutMs / 3))));
|
|
209
|
+
let timeout;
|
|
210
|
+
try {
|
|
211
|
+
this.logger.log(`[NewsBrief] generating ${profile.code} for ${params.briefDate}/${params.period}`);
|
|
212
|
+
const promise = this.generateClaimedBrief(profile, params.briefDate, params.period, generationId, sources, inputWindow, params.run);
|
|
213
|
+
const timedOut = new Promise((_, reject) => {
|
|
214
|
+
timeout = setTimeout(() => reject(new Error('News brief generation timed out')), timeoutMs);
|
|
215
|
+
});
|
|
216
|
+
const brief = await Promise.race([promise, timedOut]);
|
|
217
|
+
this.logger.log(`[NewsBrief] generated ${profile.code}: ${brief.itemCount} items`);
|
|
218
|
+
return {
|
|
219
|
+
category: profile.code,
|
|
220
|
+
categoryName: profile.name,
|
|
221
|
+
status: 'success',
|
|
222
|
+
brief,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
227
|
+
this.logger.error(`[NewsBrief] failed to generate ${profile.code}: ${message}`);
|
|
228
|
+
let retained = null;
|
|
229
|
+
try {
|
|
230
|
+
const failed = await this.newsBriefRepository.markFailed(params.briefDate, params.period, profile.code, generationId, message);
|
|
231
|
+
if (failed.retained)
|
|
232
|
+
retained = failed.brief;
|
|
233
|
+
}
|
|
234
|
+
catch (markError) {
|
|
235
|
+
this.logger.error('Failed to record news brief generation failure', markError);
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
category: profile.code,
|
|
239
|
+
categoryName: profile.name,
|
|
240
|
+
status: 'failed',
|
|
241
|
+
error: message,
|
|
242
|
+
...(retained
|
|
243
|
+
? {
|
|
244
|
+
previousBriefRetained: true,
|
|
245
|
+
brief: this.toResponse(retained),
|
|
246
|
+
}
|
|
247
|
+
: {}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
clearInterval(heartbeat);
|
|
252
|
+
if (timeout)
|
|
253
|
+
clearTimeout(timeout);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async generateClaimedBrief(profile, briefDate, period, generationId, sources, inputWindow, run) {
|
|
257
|
+
const [freshItems, historyItems] = await Promise.all([
|
|
258
|
+
this.fetchLatestSourceItems(sources, run),
|
|
259
|
+
this.loadHistoryItems(sources, inputWindow.start.getTime(), inputWindow.end.getTime(), run),
|
|
260
|
+
]);
|
|
261
|
+
const inputItems = this.mergeAndLimitItems([
|
|
262
|
+
...freshItems,
|
|
263
|
+
...historyItems,
|
|
264
|
+
]);
|
|
265
|
+
if (inputItems.length === 0) {
|
|
266
|
+
throw new Error('No hot items available for news brief');
|
|
267
|
+
}
|
|
268
|
+
const searchActive = this.isSearchActive(profile);
|
|
269
|
+
const searchEvidence = searchActive
|
|
270
|
+
? await this.buildSearchEvidence(inputItems)
|
|
271
|
+
: [];
|
|
272
|
+
const searchProvidersUsed = [
|
|
273
|
+
...new Set(searchEvidence
|
|
274
|
+
.filter((evidence) => evidence.results.length > 0)
|
|
275
|
+
.map((evidence) => evidence.provider)
|
|
276
|
+
.filter((provider) => !!provider)),
|
|
277
|
+
];
|
|
278
|
+
const analysis = await this.aiClient.analyzeNewsBrief({
|
|
279
|
+
category: profile,
|
|
280
|
+
briefDate,
|
|
281
|
+
items: inputItems,
|
|
282
|
+
outputItemCount: this.effectiveMaxItems(profile),
|
|
283
|
+
searchEvidence,
|
|
284
|
+
contentMaxChars: searchActive
|
|
285
|
+
? news_brief_ai_client_1.P1_CONTENT_MAX_CHARS
|
|
286
|
+
: news_brief_ai_client_1.P0_CONTENT_MAX_CHARS,
|
|
287
|
+
});
|
|
288
|
+
const saved = await this.newsBriefRepository.markSuccess(briefDate, period, profile.code, generationId, {
|
|
289
|
+
title: analysis.title,
|
|
290
|
+
summary: analysis.summary,
|
|
291
|
+
highlights: analysis.highlights,
|
|
292
|
+
items: analysis.items,
|
|
293
|
+
sources,
|
|
294
|
+
inputWindow,
|
|
295
|
+
rawInputItems: inputItems,
|
|
296
|
+
searchEvidence: searchEvidence,
|
|
297
|
+
model: this.aiClient.getModel(),
|
|
298
|
+
searchProvidersUsed,
|
|
299
|
+
});
|
|
300
|
+
if (!saved) {
|
|
301
|
+
throw new Error('News brief generation claim was lost');
|
|
302
|
+
}
|
|
303
|
+
return this.toResponse(saved);
|
|
304
|
+
}
|
|
305
|
+
async fetchLatestSourceItems(sources, run) {
|
|
306
|
+
const results = await (0, concurrency_1.mapWithConcurrency)(sources, this.config.sourceConcurrency, (source) => this.refreshSourceOnce(source, run));
|
|
307
|
+
return results.flat();
|
|
308
|
+
}
|
|
309
|
+
refreshSourceOnce(source, run) {
|
|
310
|
+
const pending = run.freshItems.get(source);
|
|
311
|
+
if (pending)
|
|
312
|
+
return pending;
|
|
313
|
+
const started = this.refreshSource(source, run.sourceNames);
|
|
314
|
+
run.freshItems.set(source, started);
|
|
315
|
+
return started;
|
|
316
|
+
}
|
|
317
|
+
async refreshSource(source, sourceNames) {
|
|
318
|
+
const topItemsPerSource = this.config.topItemsPerSource;
|
|
319
|
+
try {
|
|
320
|
+
const refreshed = await this.fetchHotDataTask.execute(source);
|
|
321
|
+
if (!refreshed.success || !refreshed.items?.length) {
|
|
322
|
+
this.logger.warn(`[NewsBrief] source ${source} contributed no fresh items: ${refreshed.error ?? 'empty result'}`);
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
return refreshed.items
|
|
326
|
+
.slice(0, topItemsPerSource)
|
|
327
|
+
.map((item) => this.toInputItem(source, sourceNames.get(source) ?? source, item))
|
|
328
|
+
.filter((item) => item !== null);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
this.logger.warn(`[NewsBrief] failed to refresh source ${source}: ${error instanceof Error ? error.message : String(error)}`);
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async loadHistoryItems(sources, startTime, endTime, run) {
|
|
336
|
+
const sourceNames = run.sourceNames;
|
|
337
|
+
const topItemsPerSource = this.config.topItemsPerSource;
|
|
338
|
+
const results = await Promise.all(sources.map(async (source) => {
|
|
339
|
+
const items = await this.hotItemRepository.getDataByTimeRange(source, startTime, endTime, topItemsPerSource);
|
|
340
|
+
if (!Array.isArray(items))
|
|
341
|
+
return [];
|
|
342
|
+
return items
|
|
343
|
+
.slice(0, topItemsPerSource)
|
|
344
|
+
.map((item) => this.toInputItem(source, sourceNames.get(source) ?? source, item))
|
|
345
|
+
.filter((item) => item !== null);
|
|
346
|
+
}));
|
|
347
|
+
return results.flat();
|
|
348
|
+
}
|
|
349
|
+
buildSourceNameMap() {
|
|
350
|
+
return new Map(this.hotListsService
|
|
351
|
+
.getAllSourcesWithMetadata()
|
|
352
|
+
.map((item) => [item.name, item.title]));
|
|
353
|
+
}
|
|
354
|
+
toInputItem(source, sourceName, item) {
|
|
355
|
+
if (!item.title || !item.url)
|
|
356
|
+
return null;
|
|
357
|
+
return {
|
|
358
|
+
ref: (0, crypto_1.createHash)('sha256')
|
|
359
|
+
.update(`${source}|${item.url}`)
|
|
360
|
+
.digest('hex')
|
|
361
|
+
.slice(0, 8),
|
|
362
|
+
source,
|
|
363
|
+
sourceName,
|
|
364
|
+
title: item.title,
|
|
365
|
+
desc: item.desc,
|
|
366
|
+
hot: item.hot,
|
|
367
|
+
url: item.url,
|
|
368
|
+
timestamp: typeof item.timestamp === 'number' ? item.timestamp : undefined,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
mergeAndLimitItems(items) {
|
|
372
|
+
const maxCandidates = this.config.maxCandidates;
|
|
373
|
+
const seen = new Set();
|
|
374
|
+
const bySource = new Map();
|
|
375
|
+
for (const item of items) {
|
|
376
|
+
if (!item.title || !item.url)
|
|
377
|
+
continue;
|
|
378
|
+
const key = `${item.source}:${item.url}`;
|
|
379
|
+
if (seen.has(key))
|
|
380
|
+
continue;
|
|
381
|
+
seen.add(key);
|
|
382
|
+
const sourceItems = bySource.get(item.source) ?? [];
|
|
383
|
+
sourceItems.push(item);
|
|
384
|
+
bySource.set(item.source, sourceItems);
|
|
385
|
+
}
|
|
386
|
+
const buckets = [...bySource.values()];
|
|
387
|
+
for (const sourceItems of buckets) {
|
|
388
|
+
sourceItems.sort((a, b) => this.timestampOf(b) - this.timestampOf(a));
|
|
389
|
+
}
|
|
390
|
+
const maxRank = buckets.reduce((longest, sourceItems) => Math.max(longest, sourceItems.length), 0);
|
|
391
|
+
const result = [];
|
|
392
|
+
for (let rank = 0; rank < maxRank; rank++) {
|
|
393
|
+
for (const sourceItems of buckets) {
|
|
394
|
+
const item = sourceItems[rank];
|
|
395
|
+
if (!item)
|
|
396
|
+
continue;
|
|
397
|
+
result.push(item);
|
|
398
|
+
if (result.length >= maxCandidates)
|
|
399
|
+
return result;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
timestampOf(item) {
|
|
405
|
+
if (typeof item.timestamp !== 'number' ||
|
|
406
|
+
!Number.isFinite(item.timestamp)) {
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
return item.timestamp < 1e12 ? item.timestamp * 1000 : item.timestamp;
|
|
410
|
+
}
|
|
411
|
+
effectiveMaxItems(profile) {
|
|
412
|
+
return Math.min(profile.maxItems, this.config.maxItemsPerCategory, news_brief_ai_client_1.PROMPT_BUDGET.maxOutputItems);
|
|
413
|
+
}
|
|
414
|
+
isSearchActive(profile) {
|
|
415
|
+
if (!this.config.searchEnabled)
|
|
416
|
+
return false;
|
|
417
|
+
if (!profile.search) {
|
|
418
|
+
this.logger.log(`[NewsBrief] category ${profile.code} opts out of search enhancement`);
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
if (!this.searchService.isConfigured()) {
|
|
422
|
+
this.logger.warn('[NewsBrief] search enhancement is enabled but no search provider is configured; skipping');
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
async buildSearchEvidence(inputItems) {
|
|
428
|
+
const seenTitles = new Set();
|
|
429
|
+
const candidates = inputItems
|
|
430
|
+
.filter((item) => {
|
|
431
|
+
const titleKey = item.title
|
|
432
|
+
.toLocaleLowerCase()
|
|
433
|
+
.replace(/[\s\p{P}\p{S}]+/gu, '');
|
|
434
|
+
if (!titleKey || seenTitles.has(titleKey))
|
|
435
|
+
return false;
|
|
436
|
+
seenTitles.add(titleKey);
|
|
437
|
+
return true;
|
|
438
|
+
})
|
|
439
|
+
.slice(0, Math.min(this.config.searchMaxQueries, news_brief_ai_client_1.PROMPT_BUDGET.maxSearchEvidence));
|
|
440
|
+
return (0, concurrency_1.mapWithConcurrency)(candidates, this.config.searchConcurrency, async (item) => {
|
|
441
|
+
const query = this.buildSearchQuery(item);
|
|
442
|
+
const cacheKey = `${SEARCH_CACHE_PREFIX}${(0, crypto_1.createHash)('sha256')
|
|
443
|
+
.update(`${this.config.searchProviders.join(',')}:${query}`)
|
|
444
|
+
.digest('hex')}`;
|
|
445
|
+
const cached = await this.cacheService.get(cacheKey);
|
|
446
|
+
const outcome = cached?.data ?? (await this.searchService.search(query));
|
|
447
|
+
if (!cached && outcome.results.length > 0) {
|
|
448
|
+
await this.cacheService.set(cacheKey, { data: outcome, updateTime: new Date().toISOString() }, SEARCH_CACHE_TTL_SECONDS);
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
ref: item.ref,
|
|
452
|
+
topicTitle: item.title,
|
|
453
|
+
query,
|
|
454
|
+
provider: outcome.provider,
|
|
455
|
+
results: outcome.results,
|
|
456
|
+
};
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
buildSearchQuery(item) {
|
|
460
|
+
const base = `${item.title} ${item.desc ?? ''}`
|
|
461
|
+
.replace(/\s+/gu, ' ')
|
|
462
|
+
.trim();
|
|
463
|
+
return base.length > this.config.searchQueryMaxChars
|
|
464
|
+
? base.slice(0, this.config.searchQueryMaxChars)
|
|
465
|
+
: base;
|
|
466
|
+
}
|
|
467
|
+
resolveDigestProfiles(raw) {
|
|
468
|
+
if (!raw)
|
|
469
|
+
return this.config.categories;
|
|
470
|
+
const codes = (Array.isArray(raw) ? raw.join(',') : raw)
|
|
471
|
+
.split(',')
|
|
472
|
+
.map((code) => code.trim())
|
|
473
|
+
.filter(Boolean);
|
|
474
|
+
if (codes.length === 0)
|
|
475
|
+
return this.config.categories;
|
|
476
|
+
const enabled = new Map(this.config.categories.map((profile) => [profile.code, profile]));
|
|
477
|
+
return [...new Set(codes)].map((code) => {
|
|
478
|
+
const known = (0, news_category_registry_1.getNewsCategoryProfile)(code);
|
|
479
|
+
if (!known) {
|
|
480
|
+
throw new common_1.BadRequestException(this.unknownCategoryMessage(code));
|
|
481
|
+
}
|
|
482
|
+
const profile = enabled.get(code);
|
|
483
|
+
if (!profile) {
|
|
484
|
+
throw new common_1.BadRequestException(`Category "${code}" is not enabled. Enabled: ${[
|
|
485
|
+
...enabled.keys(),
|
|
486
|
+
].join(', ')}`);
|
|
487
|
+
}
|
|
488
|
+
return profile;
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
resolveGenerateProfiles(category) {
|
|
492
|
+
const enabled = this.config.categories;
|
|
493
|
+
if (!category)
|
|
494
|
+
return enabled;
|
|
495
|
+
const profile = enabled.find((item) => item.code === category);
|
|
496
|
+
if (!profile) {
|
|
497
|
+
const known = (0, news_category_registry_1.getNewsCategoryProfile)(category);
|
|
498
|
+
throw new common_1.BadRequestException(known
|
|
499
|
+
? `Category "${category}" is not enabled. Enabled: ${enabled
|
|
500
|
+
.map((item) => item.code)
|
|
501
|
+
.join(', ')}`
|
|
502
|
+
: this.unknownCategoryMessage(category));
|
|
503
|
+
}
|
|
504
|
+
return [profile];
|
|
505
|
+
}
|
|
506
|
+
requireKnownCategory(category) {
|
|
507
|
+
if (!category) {
|
|
508
|
+
throw new common_1.BadRequestException(`category is required. Available: ${this.config.knownCategories
|
|
509
|
+
.map((item) => item.code)
|
|
510
|
+
.join(', ')}`);
|
|
511
|
+
}
|
|
512
|
+
const profile = (0, news_category_registry_1.getNewsCategoryProfile)(category);
|
|
513
|
+
if (!profile) {
|
|
514
|
+
throw new common_1.BadRequestException(this.unknownCategoryMessage(category));
|
|
515
|
+
}
|
|
516
|
+
return profile;
|
|
517
|
+
}
|
|
518
|
+
unknownCategoryMessage(category) {
|
|
519
|
+
return `Unknown category "${category}". Available: ${this.config.knownCategories
|
|
520
|
+
.map((item) => item.code)
|
|
521
|
+
.join(', ')}`;
|
|
522
|
+
}
|
|
523
|
+
resolveSourcesOverride(options) {
|
|
524
|
+
if (!options.sources || options.sources.length === 0)
|
|
525
|
+
return undefined;
|
|
526
|
+
if (!options.category) {
|
|
527
|
+
throw new common_1.BadRequestException('sources override requires an explicit category');
|
|
528
|
+
}
|
|
529
|
+
const registered = new Set(this.hotListsService.getAllSources());
|
|
530
|
+
const unknown = options.sources.filter((source) => !registered.has(source));
|
|
531
|
+
if (unknown.length > 0) {
|
|
532
|
+
throw new common_1.BadRequestException(`Unknown hot list source(s): ${unknown.join(', ')}`);
|
|
533
|
+
}
|
|
534
|
+
return [...new Set(options.sources)];
|
|
535
|
+
}
|
|
536
|
+
resolveBriefDate(date) {
|
|
537
|
+
if (!date) {
|
|
538
|
+
return (0, brief_date_1.formatBriefDate)(new Date(), this.config.timezone);
|
|
539
|
+
}
|
|
540
|
+
return this.validateDate(date);
|
|
541
|
+
}
|
|
542
|
+
validateDate(date) {
|
|
543
|
+
if (!DATE_PATTERN.test(date)) {
|
|
544
|
+
throw new common_1.BadRequestException('date must be in YYYY-MM-DD format');
|
|
545
|
+
}
|
|
546
|
+
return date;
|
|
547
|
+
}
|
|
548
|
+
normalizePeriod(period) {
|
|
549
|
+
const raw = typeof period === 'string' ? period.trim() : '';
|
|
550
|
+
const value = raw || 'daily';
|
|
551
|
+
if (!PERIOD_PATTERN.test(value)) {
|
|
552
|
+
throw new common_1.BadRequestException('period must match ^[a-z0-9][a-z0-9-]{0,31}$');
|
|
553
|
+
}
|
|
554
|
+
return value;
|
|
555
|
+
}
|
|
556
|
+
normalizeOptionalPeriod(period) {
|
|
557
|
+
const raw = typeof period === 'string' ? period.trim() : '';
|
|
558
|
+
if (!raw)
|
|
559
|
+
return undefined;
|
|
560
|
+
if (!PERIOD_PATTERN.test(raw)) {
|
|
561
|
+
throw new common_1.BadRequestException('period must match ^[a-z0-9][a-z0-9-]{0,31}$');
|
|
562
|
+
}
|
|
563
|
+
return raw;
|
|
564
|
+
}
|
|
565
|
+
resolveOlderThanDate(olderThan) {
|
|
566
|
+
if (!olderThan) {
|
|
567
|
+
throw new common_1.BadRequestException('olderThan or beforeDate is required');
|
|
568
|
+
}
|
|
569
|
+
const normalized = olderThan.trim().toLowerCase();
|
|
570
|
+
const aliases = {
|
|
571
|
+
month: '1m',
|
|
572
|
+
'one-month': '1m',
|
|
573
|
+
year: '1y',
|
|
574
|
+
'one-year': '1y',
|
|
575
|
+
};
|
|
576
|
+
const value = aliases[normalized] || normalized;
|
|
577
|
+
const match = value.match(/^(\d+)(d|day|days|m|month|months|y|year|years)$/);
|
|
578
|
+
if (!match) {
|
|
579
|
+
throw new common_1.BadRequestException('olderThan must use a value like 30d, 1m, or 1y');
|
|
580
|
+
}
|
|
581
|
+
const amount = Number(match[1]);
|
|
582
|
+
const unit = match[2];
|
|
583
|
+
const cutoff = new Date();
|
|
584
|
+
if (unit.startsWith('d')) {
|
|
585
|
+
cutoff.setDate(cutoff.getDate() - amount);
|
|
586
|
+
}
|
|
587
|
+
else if (unit.startsWith('m')) {
|
|
588
|
+
cutoff.setMonth(cutoff.getMonth() - amount);
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
cutoff.setFullYear(cutoff.getFullYear() - amount);
|
|
592
|
+
}
|
|
593
|
+
return (0, brief_date_1.formatBriefDate)(cutoff, this.config.timezone);
|
|
594
|
+
}
|
|
595
|
+
resolveInputWindow(briefDate, lookbackHours) {
|
|
596
|
+
const now = new Date();
|
|
597
|
+
const today = (0, brief_date_1.formatBriefDate)(now, this.config.timezone);
|
|
598
|
+
const end = briefDate === today
|
|
599
|
+
? now
|
|
600
|
+
: (0, brief_date_1.endOfBriefDate)(briefDate, this.config.timezone);
|
|
601
|
+
const start = new Date(end.getTime() - lookbackHours * 60 * 60 * 1000);
|
|
602
|
+
return { start, end, lookbackHours };
|
|
603
|
+
}
|
|
604
|
+
toResponse(brief, includeDebug = false) {
|
|
605
|
+
const items = (brief.items ?? []).map((item) => ({
|
|
606
|
+
id: item.id,
|
|
607
|
+
title: item.title,
|
|
608
|
+
summary: item.summary,
|
|
609
|
+
content: item.content,
|
|
610
|
+
source: {
|
|
611
|
+
code: item.source?.code ?? '',
|
|
612
|
+
name: item.source?.name ?? '',
|
|
613
|
+
url: item.source?.url ?? '',
|
|
614
|
+
},
|
|
615
|
+
...(item.publishedAt ? { publishedAt: item.publishedAt } : {}),
|
|
616
|
+
...(item.tags && item.tags.length > 0 ? { tags: item.tags } : {}),
|
|
617
|
+
...(typeof item.importance === 'number'
|
|
618
|
+
? { importance: item.importance }
|
|
619
|
+
: {}),
|
|
620
|
+
...(item.extensions ? { extensions: item.extensions } : {}),
|
|
621
|
+
}));
|
|
622
|
+
const response = {
|
|
623
|
+
briefDate: brief.briefDate,
|
|
624
|
+
period: brief.period,
|
|
625
|
+
category: brief.category,
|
|
626
|
+
categoryName: brief.categoryName ?? brief.category,
|
|
627
|
+
status: brief.status,
|
|
628
|
+
...(brief.title ? { title: brief.title } : {}),
|
|
629
|
+
...(brief.summary ? { summary: brief.summary } : {}),
|
|
630
|
+
highlights: brief.highlights ?? [],
|
|
631
|
+
items,
|
|
632
|
+
itemCount: items.length,
|
|
633
|
+
sources: brief.sources ?? [],
|
|
634
|
+
inputWindow: brief.inputWindow
|
|
635
|
+
? {
|
|
636
|
+
start: new Date(brief.inputWindow.start).toISOString(),
|
|
637
|
+
end: new Date(brief.inputWindow.end).toISOString(),
|
|
638
|
+
lookbackHours: brief.inputWindow.lookbackHours ?? 0,
|
|
639
|
+
}
|
|
640
|
+
: {
|
|
641
|
+
start: new Date(0).toISOString(),
|
|
642
|
+
end: new Date(0).toISOString(),
|
|
643
|
+
lookbackHours: 0,
|
|
644
|
+
},
|
|
645
|
+
...(brief.model ? { model: brief.model } : {}),
|
|
646
|
+
searchProvidersUsed: brief.searchProvidersUsed ?? [],
|
|
647
|
+
...(brief.error ? { error: brief.error } : {}),
|
|
648
|
+
...(brief.createdAt
|
|
649
|
+
? { createdAt: new Date(brief.createdAt).toISOString() }
|
|
650
|
+
: {}),
|
|
651
|
+
...(brief.updatedAt
|
|
652
|
+
? { updatedAt: new Date(brief.updatedAt).toISOString() }
|
|
653
|
+
: {}),
|
|
654
|
+
};
|
|
655
|
+
if (includeDebug) {
|
|
656
|
+
return Object.assign(response, {
|
|
657
|
+
rawInputItems: brief.rawInputItems ?? [],
|
|
658
|
+
searchEvidence: brief.searchEvidence ?? [],
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
return response;
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
exports.NewsBriefService = NewsBriefService;
|
|
665
|
+
exports.NewsBriefService = NewsBriefService = NewsBriefService_1 = __decorate([
|
|
666
|
+
(0, common_1.Injectable)(),
|
|
667
|
+
__metadata("design:paramtypes", [news_brief_config_1.NewsBriefConfigService,
|
|
668
|
+
news_brief_repository_1.NewsBriefRepository,
|
|
669
|
+
hot_item_repository_1.HotItemRepository,
|
|
670
|
+
fetch_hot_data_task_1.FetchHotDataTask,
|
|
671
|
+
hot_lists_service_1.HotListsService,
|
|
672
|
+
news_brief_ai_client_1.NewsBriefAiClient,
|
|
673
|
+
news_brief_search_service_1.NewsBriefSearchService,
|
|
674
|
+
cache_service_1.CacheService])
|
|
675
|
+
], NewsBriefService);
|
|
676
|
+
//# sourceMappingURL=news-brief.service.js.map
|