diff --git a/__tests__/workers/generateChannelHighlight.ts b/__tests__/workers/generateChannelHighlight.ts index 4d1032eed0..01acd9c725 100644 --- a/__tests__/workers/generateChannelHighlight.ts +++ b/__tests__/workers/generateChannelHighlight.ts @@ -456,6 +456,82 @@ describe('generateChannelHighlight worker', () => { expect(retiredHighlights[0].retiredAt).toBeInstanceOf(Date); }); + it('should send recent retired highlights to the evaluator while excluding resurfaced stories', async () => { + const now = new Date('2026-03-03T12:00:00.000Z'); + await con.getRepository(ChannelHighlightDefinition).save({ + channel: 'vibes', + mode: 'shadow', + candidateHorizonHours: 72, + maxItems: 3, + }); + await saveCollection({ + id: 'collection-1', + title: 'Collection story', + createdAt: new Date('2026-03-03T11:50:00.000Z'), + }); + await saveArticle({ + id: 'retired-child', + title: 'Retired child story', + createdAt: new Date('2026-03-03T11:20:00.000Z'), + }); + await saveArticle({ + id: 'fresh-child', + title: 'Fresh child story', + createdAt: new Date('2026-03-03T11:55:00.000Z'), + }); + await saveArticle({ + id: 'fresh-stand-1', + title: 'Fresh standalone story', + createdAt: new Date('2026-03-03T11:58:00.000Z'), + }); + await con.getRepository(PostRelation).save([ + { + postId: 'collection-1', + relatedPostId: 'retired-child', + type: PostRelationType.Collection, + }, + { + postId: 'collection-1', + relatedPostId: 'fresh-child', + type: PostRelationType.Collection, + }, + ]); + await con.getRepository(PostHighlight).save({ + channel: 'vibes', + postId: 'retired-child', + highlightedAt: new Date('2026-03-03T11:30:00.000Z'), + headline: 'Retired child headline', + significance: PostHighlightSignificance.Notable, + retiredAt: new Date('2026-03-03T11:40:00.000Z'), + }); + + const evaluatorSpy = jest + .spyOn(evaluator, 'evaluateChannelHighlights') + .mockResolvedValue({ items: [] }); + + await expectSuccessfulTypedBackground<'api.v1.generate-channel-highlight'>( + worker, + { + channel: 'vibes', + scheduledAt: now.toISOString(), + }, + ); + + expect(evaluatorSpy).toHaveBeenCalledTimes(1); + expect(evaluatorSpy.mock.calls[0][0].currentHighlights).toEqual([ + expect.objectContaining({ + postId: 'collection-1', + headline: 'Retired child headline', + }), + ]); + expect(evaluatorSpy.mock.calls[0][0].newCandidates).toEqual([ + expect.objectContaining({ + postId: 'fresh-stand-1', + title: 'Fresh standalone story', + }), + ]); + }); + it('should ignore posts from channel digest sources for highlights', async () => { const now = new Date('2026-03-03T11:50:00.000Z'); await con @@ -645,6 +721,58 @@ describe('generateChannelHighlight worker', () => { ]); }); + it('should exclude posts refreshed only by stats updates from incremental candidates', async () => { + const now = new Date('2026-03-03T12:45:00.000Z'); + await con.getRepository(ChannelHighlightDefinition).save({ + channel: 'vibes', + mode: 'shadow', + candidateHorizonHours: 72, + maxItems: 3, + lastFetchedAt: new Date('2026-03-03T12:20:00.000Z'), + }); + await saveArticle({ + id: 'stats-only-1', + title: 'Stats only refresh', + createdAt: new Date('2026-03-02T12:00:00.000Z'), + metadataChangedAt: new Date('2026-03-02T12:00:00.000Z'), + statsUpdatedAt: new Date('2026-03-03T12:40:00.000Z'), + }); + await saveArticle({ + id: 'fresh-1', + title: 'Fresh candidate', + createdAt: new Date('2026-03-03T12:30:00.000Z'), + }); + + const evaluatorSpy = jest + .spyOn(evaluator, 'evaluateChannelHighlights') + .mockResolvedValue({ + items: [ + { + postId: 'fresh-1', + headline: 'Fresh headline', + significanceLabel: 'notable', + reason: 'test', + }, + ], + }); + + await expectSuccessfulTypedBackground<'api.v1.generate-channel-highlight'>( + worker, + { + channel: 'vibes', + scheduledAt: now.toISOString(), + }, + ); + + expect(evaluatorSpy).toHaveBeenCalledTimes(1); + expect(evaluatorSpy.mock.calls[0][0].newCandidates).toEqual([ + expect.objectContaining({ + postId: 'fresh-1', + title: 'Fresh candidate', + }), + ]); + }); + it('should exclude posts with rejected content curation types from candidates', async () => { const now = new Date('2026-03-03T13:00:00.000Z'); await con.getRepository(ChannelHighlightDefinition).save({ diff --git a/src/common/channelHighlight/generate.ts b/src/common/channelHighlight/generate.ts index d33b2cd9f6..2219bfb2a3 100644 --- a/src/common/channelHighlight/generate.ts +++ b/src/common/channelHighlight/generate.ts @@ -8,10 +8,12 @@ import { evaluateChannelHighlights } from './evaluate'; import { replaceHighlightsForChannel } from './publish'; import { fetchCurrentHighlights, + fetchEvaluationHistoryHighlights, fetchIncrementalPosts, fetchPostsByIds, fetchRetiredHighlightPostIds, fetchRelations, + getEvaluationHistoryStart, getFetchStart, getHorizonStart, mergePosts, @@ -68,20 +70,29 @@ export const generateChannelHighlight = async ({ ); try { - const [currentHighlights, retiredHighlightPostIds, excludedSourceIds] = - await Promise.all([ - fetchCurrentHighlights({ - con, - channel: definition.channel, - }), - fetchRetiredHighlightPostIds({ - con, - channel: definition.channel, - }), - getChannelDigestSourceIds({ - con, - }), - ]); + const [ + currentHighlights, + retiredHighlightPostIds, + excludedSourceIds, + evaluationHistoryHighlights, + ] = await Promise.all([ + fetchCurrentHighlights({ + con, + channel: definition.channel, + }), + fetchRetiredHighlightPostIds({ + con, + channel: definition.channel, + }), + getChannelDigestSourceIds({ + con, + }), + fetchEvaluationHistoryHighlights({ + con, + channel: definition.channel, + now, + }), + ]); const horizonStart = getHorizonStart({ now, definition, @@ -97,21 +108,34 @@ export const generateChannelHighlight = async ({ ); const highlightedPostIds = activeHighlights.map((item) => item.postId); - const [incrementalPosts, highlightedPosts] = await Promise.all([ - fetchIncrementalPosts({ - con, - channel: definition.channel, - fetchStart, - horizonStart, - excludedSourceIds, - }), - fetchPostsByIds({ - con, - ids: highlightedPostIds, - excludedSourceIds, - }), + const evaluationHistoryPostIds = evaluationHistoryHighlights.map( + (item) => item.postId, + ); + const [incrementalPosts, highlightedPosts, evaluationHistoryPosts] = + await Promise.all([ + fetchIncrementalPosts({ + con, + channel: definition.channel, + fetchStart, + horizonStart, + excludedSourceIds, + }), + fetchPostsByIds({ + con, + ids: highlightedPostIds, + excludedSourceIds, + }), + fetchPostsByIds({ + con, + ids: evaluationHistoryPostIds, + excludedSourceIds, + }), + ]); + const basePosts = mergePosts([ + incrementalPosts, + highlightedPosts, + evaluationHistoryPosts, ]); - const basePosts = mergePosts([incrementalPosts, highlightedPosts]); const relations = await fetchRelations({ con, postIds: basePosts.map((post) => post.id), @@ -134,11 +158,26 @@ export const generateChannelHighlight = async ({ relations, posts: availablePosts, }); + const evaluationHighlights = canonicalizeCurrentHighlights({ + highlights: evaluationHistoryHighlights.map(toHighlightItem), + relations, + posts: availablePosts, + }); + const retiredEvaluationHighlights = canonicalizeCurrentHighlights({ + highlights: evaluationHistoryHighlights + .filter((item) => !!item.retiredAt) + .map(toHighlightItem), + relations, + posts: availablePosts, + }); const currentHighlightPostIds = new Set( liveHighlights.map((item) => item.postId), ); const retiredHighlightPostIdSet = new Set(retiredHighlightPostIds); + const retiredEvaluationPostIdSet = new Set( + retiredEvaluationHighlights.map((item) => item.postId), + ); const newCandidates = buildCandidates({ posts: availablePosts, relations, @@ -146,7 +185,8 @@ export const generateChannelHighlight = async ({ }).filter( (candidate) => !currentHighlightPostIds.has(candidate.postId) && - !retiredHighlightPostIdSet.has(candidate.postId), + !retiredHighlightPostIdSet.has(candidate.postId) && + !retiredEvaluationPostIdSet.has(candidate.postId), ); const admittedHighlights = @@ -159,7 +199,7 @@ export const generateChannelHighlight = async ({ definition.targetAudience.trim() || `daily.dev readers following ${definition.channel}`, maxItems: definition.maxItems, - currentHighlights: liveHighlights, + currentHighlights: evaluationHighlights, newCandidates, }) ).items.map((item) => ({ @@ -205,8 +245,17 @@ export const generateChannelHighlight = async ({ inputSummary: { fetchStart: fetchStart.toISOString(), horizonStart: horizonStart.toISOString(), + evaluationHistoryStart: getEvaluationHistoryStart({ + now, + }).toISOString(), excludedSourceIds, currentHighlightPostIds: liveHighlights.map((item) => item.postId), + evaluationHighlightPostIds: evaluationHighlights.map( + (item) => item.postId, + ), + retiredEvaluationHighlightPostIds: retiredEvaluationHighlights.map( + (item) => item.postId, + ), retiredHighlightPostIds, candidatePostIds: newCandidates.map( (candidate) => candidate.postId, @@ -224,6 +273,8 @@ export const generateChannelHighlight = async ({ currentHighlights: baselineHighlights.length, activeHighlights: activeHighlights.length, canonicalizedHighlights: liveHighlights.length, + evaluationHighlights: evaluationHighlights.length, + retiredEvaluationHighlights: retiredEvaluationHighlights.length, newCandidates: newCandidates.length, admittedHighlights: admittedHighlights.length, }, diff --git a/src/common/channelHighlight/queries.ts b/src/common/channelHighlight/queries.ts index 629df2a7fb..dc11b4b458 100644 --- a/src/common/channelHighlight/queries.ts +++ b/src/common/channelHighlight/queries.ts @@ -1,5 +1,12 @@ -import { Brackets, In, IsNull, Not, type DataSource } from 'typeorm'; -import { ONE_HOUR_IN_SECONDS } from '../constants'; +import { + Brackets, + In, + IsNull, + MoreThanOrEqual, + Not, + type DataSource, +} from 'typeorm'; +import { ONE_HOUR_IN_SECONDS, ONE_WEEK_IN_SECONDS } from '../constants'; import { PostHighlight } from '../../entity/PostHighlight'; import { Post } from '../../entity/posts/Post'; import { @@ -20,6 +27,7 @@ const REJECTED_CONTENT_CURATIONS = [ ]; const HIGHLIGHT_FETCH_OVERLAP_SECONDS = 10 * 60; +const HIGHLIGHT_EVALUATION_HISTORY_SECONDS = ONE_WEEK_IN_SECONDS; export const getHorizonStart = ({ now, @@ -76,6 +84,32 @@ export const fetchCurrentHighlights = async ({ }, }); +export const getEvaluationHistoryStart = ({ now }: { now: Date }): Date => + new Date(now.getTime() - HIGHLIGHT_EVALUATION_HISTORY_SECONDS * 1000); + +export const fetchEvaluationHistoryHighlights = async ({ + con, + channel, + now, +}: { + con: DataSource; + channel: string; + now: Date; +}): Promise => + con.getRepository(PostHighlight).find({ + where: { + channel, + highlightedAt: MoreThanOrEqual( + getEvaluationHistoryStart({ + now, + }), + ), + }, + order: { + highlightedAt: 'DESC', + }, + }); + export const fetchRetiredHighlightPostIds = async ({ con, channel, @@ -158,8 +192,7 @@ export const fetchIncrementalPosts = async ({ new Brackets((builder) => { builder .where('post.createdAt >= :fetchStart', { fetchStart }) - .orWhere('post.metadataChangedAt >= :fetchStart', { fetchStart }) - .orWhere('post.statsUpdatedAt >= :fetchStart', { fetchStart }); + .orWhere('post.metadataChangedAt >= :fetchStart', { fetchStart }); }), ) .getMany() as unknown as Promise;