Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions __tests__/workers/generateChannelHighlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
111 changes: 81 additions & 30 deletions src/common/channelHighlight/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { evaluateChannelHighlights } from './evaluate';
import { replaceHighlightsForChannel } from './publish';
import {
fetchCurrentHighlights,
fetchEvaluationHistoryHighlights,
fetchIncrementalPosts,
fetchPostsByIds,
fetchRetiredHighlightPostIds,
fetchRelations,
getEvaluationHistoryStart,
getFetchStart,
getHorizonStart,
mergePosts,
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand All @@ -134,19 +158,35 @@ 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,
horizonStart,
}).filter(
(candidate) =>
!currentHighlightPostIds.has(candidate.postId) &&
!retiredHighlightPostIdSet.has(candidate.postId),
!retiredHighlightPostIdSet.has(candidate.postId) &&
!retiredEvaluationPostIdSet.has(candidate.postId),
);

const admittedHighlights =
Expand All @@ -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<HighlightItem>((item) => ({
Expand Down Expand Up @@ -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,
Expand All @@ -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,
},
Expand Down
41 changes: 37 additions & 4 deletions src/common/channelHighlight/queries.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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<PostHighlight[]> =>
con.getRepository(PostHighlight).find({
where: {
channel,
highlightedAt: MoreThanOrEqual(
getEvaluationHistoryStart({
now,
}),
),
},
order: {
highlightedAt: 'DESC',
},
});

export const fetchRetiredHighlightPostIds = async ({
con,
channel,
Expand Down Expand Up @@ -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<HighlightPost[]>;
Expand Down
Loading