@ttkit/daily-hot-api 0.1.3 → 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.
Files changed (61) hide show
  1. package/.env.example +24 -0
  2. package/README.md +1 -0
  3. package/dist/app.module.js +2 -0
  4. package/dist/app.module.js.map +1 -1
  5. package/dist/config/validation.schema.d.ts +51 -0
  6. package/dist/config/validation.schema.js +69 -0
  7. package/dist/config/validation.schema.js.map +1 -1
  8. package/dist/daily-brief/daily-brief.module.js +1 -1
  9. package/dist/daily-brief/daily-brief.module.js.map +1 -1
  10. package/dist/daily-brief/daily-brief.service.js +1 -1
  11. package/dist/daily-brief/daily-brief.service.js.map +1 -1
  12. package/dist/database/database.module.js +15 -2
  13. package/dist/database/database.module.js.map +1 -1
  14. package/dist/database/repositories/daily-brief.repository.js +11 -1
  15. package/dist/database/repositories/daily-brief.repository.js.map +1 -1
  16. package/dist/database/repositories/hot-item.repository.d.ts +1 -1
  17. package/dist/database/repositories/hot-item.repository.js +7 -4
  18. package/dist/database/repositories/hot-item.repository.js.map +1 -1
  19. package/dist/database/repositories/news-brief.repository.d.ts +81 -0
  20. package/dist/database/repositories/news-brief.repository.js +208 -0
  21. package/dist/database/repositories/news-brief.repository.js.map +1 -0
  22. package/dist/database/schemas/news-brief.schema.d.ts +48 -0
  23. package/dist/database/schemas/news-brief.schema.js +130 -0
  24. package/dist/database/schemas/news-brief.schema.js.map +1 -0
  25. package/dist/news-brief/categories/news-category.registry.d.ts +5 -0
  26. package/dist/news-brief/categories/news-category.registry.js +106 -0
  27. package/dist/news-brief/categories/news-category.registry.js.map +1 -0
  28. package/dist/news-brief/clients/news-brief-ai.client.d.ts +45 -0
  29. package/dist/news-brief/clients/news-brief-ai.client.js +314 -0
  30. package/dist/news-brief/clients/news-brief-ai.client.js.map +1 -0
  31. package/dist/news-brief/clients/news-brief-search.service.d.ts +12 -0
  32. package/dist/news-brief/clients/news-brief-search.service.js +57 -0
  33. package/dist/news-brief/clients/news-brief-search.service.js.map +1 -0
  34. package/dist/news-brief/dto/generate-news-brief.dto.d.ts +7 -0
  35. package/dist/news-brief/dto/generate-news-brief.dto.js +45 -0
  36. package/dist/news-brief/dto/generate-news-brief.dto.js.map +1 -0
  37. package/dist/news-brief/dto/list-news-briefs-query.dto.d.ts +7 -0
  38. package/dist/news-brief/dto/list-news-briefs-query.dto.js +59 -0
  39. package/dist/news-brief/dto/list-news-briefs-query.dto.js.map +1 -0
  40. package/dist/news-brief/dto/news-brief-date-param.dto.d.ts +3 -0
  41. package/dist/news-brief/dto/news-brief-date-param.dto.js +22 -0
  42. package/dist/news-brief/dto/news-brief-date-param.dto.js.map +1 -0
  43. package/dist/news-brief/interfaces/news-brief.interface.d.ts +202 -0
  44. package/dist/news-brief/interfaces/news-brief.interface.js +3 -0
  45. package/dist/news-brief/interfaces/news-brief.interface.js.map +1 -0
  46. package/dist/news-brief/news-brief.config.d.ts +33 -0
  47. package/dist/news-brief/news-brief.config.js +118 -0
  48. package/dist/news-brief/news-brief.config.js.map +1 -0
  49. package/dist/news-brief/news-brief.controller.d.ts +56 -0
  50. package/dist/news-brief/news-brief.controller.js +185 -0
  51. package/dist/news-brief/news-brief.controller.js.map +1 -0
  52. package/dist/news-brief/news-brief.module.d.ts +2 -0
  53. package/dist/news-brief/news-brief.module.js +47 -0
  54. package/dist/news-brief/news-brief.module.js.map +1 -0
  55. package/dist/news-brief/news-brief.scheduler.d.ts +34 -0
  56. package/dist/news-brief/news-brief.scheduler.js +143 -0
  57. package/dist/news-brief/news-brief.scheduler.js.map +1 -0
  58. package/dist/news-brief/news-brief.service.d.ts +60 -0
  59. package/dist/news-brief/news-brief.service.js +676 -0
  60. package/dist/news-brief/news-brief.service.js.map +1 -0
  61. package/package.json +1 -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