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
182 changes: 180 additions & 2 deletions __tests__/workers/generateChannelHighlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import createOrGetConnection from '../../src/db';
import { ChannelDigest } from '../../src/entity/ChannelDigest';
import { ChannelHighlightDefinition } from '../../src/entity/ChannelHighlightDefinition';
import { ChannelHighlightRun } from '../../src/entity/ChannelHighlightRun';
import { AGENTS_DIGEST_SOURCE } from '../../src/entity/Source';
import { AGENTS_DIGEST_SOURCE, UNKNOWN_SOURCE } from '../../src/entity/Source';
import {
PostHighlight,
PostHighlightSignificance,
} from '../../src/entity/PostHighlight';
import { ArticlePost, CollectionPost, Source } from '../../src/entity';
import {
ArticlePost,
CollectionPost,
SharePost,
Source,
} from '../../src/entity';
import {
PostRelation,
PostRelationType,
Expand Down Expand Up @@ -102,6 +107,41 @@ const saveCollection = async ({
},
});

const saveShare = async ({
id,
sharedPostId,
createdAt,
sourceId = 'content-source',
title = 'Shared story',
upvotes = 0,
private: isPrivate = false,
}: {
id: string;
sharedPostId: string;
createdAt: Date;
sourceId?: string;
title?: string;
upvotes?: number;
private?: boolean;
}) =>
con.getRepository(SharePost).save({
id,
shortId: id,
title,
sourceId,
sharedPostId,
createdAt,
metadataChangedAt: createdAt,
statsUpdatedAt: createdAt,
visible: true,
deleted: false,
banned: false,
private: isPrivate,
showOnFeed: true,
upvotes,
type: PostType.Share,
});

describe('generateChannelHighlight worker', () => {
beforeEach(async () => {
await con
Expand Down Expand Up @@ -721,6 +761,144 @@ describe('generateChannelHighlight worker', () => {
]);
});

it('should replace unknown-source candidates with the most upvoted public share before evaluation', async () => {
const now = new Date('2026-03-03T12:40:00.000Z');
await con.getRepository(ChannelHighlightDefinition).save({
channel: 'vibes',
mode: 'publish',
candidateHorizonHours: 72,
maxItems: 3,
});
await con
.getRepository(Source)
.save(
createSource(
UNKNOWN_SOURCE,
'Unknown',
'https://daily.dev/unknown.png',
undefined,
true,
),
);
await saveArticle({
id: 'unk-orig-1',
sourceId: UNKNOWN_SOURCE,
title: 'Unknown source story',
createdAt: new Date('2026-03-03T12:20:00.000Z'),
});
await saveShare({
id: 'pub-share-1',
sharedPostId: 'unk-orig-1',
createdAt: new Date('2026-03-03T12:26:00.000Z'),
upvotes: 25,
});
await saveShare({
id: 'pub-share-2',
sharedPostId: 'unk-orig-1',
createdAt: new Date('2026-03-03T12:25:00.000Z'),
upvotes: 50,
});
await saveShare({
id: 'priv-share1',
sharedPostId: 'unk-orig-1',
createdAt: new Date('2026-03-03T12:27:00.000Z'),
upvotes: 100,
private: true,
});

const evaluatorSpy = jest
.spyOn(evaluator, 'evaluateChannelHighlights')
.mockImplementation(async ({ newCandidates }) => ({
items: [
{
postId: newCandidates[0].postId,
headline: 'Shared headline',
significanceLabel: 'breaking',
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: 'pub-share-2',
title: 'Unknown source story',
}),
]);

const liveHighlights = await con.getRepository(PostHighlight).find({
where: { channel: 'vibes', retiredAt: IsNull() },
});
expect(liveHighlights).toEqual([
expect.objectContaining({
postId: 'pub-share-2',
headline: 'Shared headline',
significance: PostHighlightSignificance.Breaking,
reason: 'test',
}),
]);
});

it('should skip unknown-source candidates when no public share exists', async () => {
const now = new Date('2026-03-03T12:41:00.000Z');
await con.getRepository(ChannelHighlightDefinition).save({
channel: 'vibes',
mode: 'publish',
candidateHorizonHours: 72,
maxItems: 3,
});
await con
.getRepository(Source)
.save(
createSource(
UNKNOWN_SOURCE,
'Unknown',
'https://daily.dev/unknown.png',
undefined,
true,
),
);
await saveArticle({
id: 'unk-orig-2',
sourceId: UNKNOWN_SOURCE,
title: 'Unknown source story 2',
createdAt: new Date('2026-03-03T12:21:00.000Z'),
});
await saveShare({
id: 'priv-share2',
sharedPostId: 'unk-orig-2',
createdAt: new Date('2026-03-03T12:28:00.000Z'),
upvotes: 100,
private: true,
});

const evaluatorSpy = jest.spyOn(evaluator, 'evaluateChannelHighlights');

await expectSuccessfulTypedBackground<'api.v1.generate-channel-highlight'>(
worker,
{
channel: 'vibes',
scheduledAt: now.toISOString(),
},
);

expect(evaluatorSpy).not.toHaveBeenCalled();

const liveHighlights = await con.getRepository(PostHighlight).find({
where: { channel: 'vibes', retiredAt: IsNull() },
});
expect(liveHighlights).toEqual([]);
});

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({
Expand Down
77 changes: 58 additions & 19 deletions src/common/channelHighlight/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { DataSource } from 'typeorm';
import { logger as baseLogger } from '../../logger';
import { ChannelHighlightDefinition } from '../../entity/ChannelHighlightDefinition';
import { ChannelHighlightRun } from '../../entity/ChannelHighlightRun';
import { UNKNOWN_SOURCE } from '../../entity/Source';
import { getChannelDigestSourceIds } from '../channelDigest/definitions';
import { compareSnapshots } from './decisions';
import { evaluateChannelHighlights } from './evaluate';
Expand All @@ -11,6 +12,7 @@ import {
fetchEvaluationHistoryHighlights,
fetchIncrementalPosts,
fetchPostsByIds,
fetchPublicShareFallbackPostIds,
fetchRetiredHighlightPostIds,
fetchRelations,
getEvaluationHistoryStart,
Expand All @@ -19,6 +21,8 @@ import {
mergePosts,
} from './queries';
import {
applyPublicShareFallbackToCandidates,
applyPublicShareFallbackToHighlights,
buildCandidates,
canonicalizeCurrentHighlights,
toHighlightItem,
Expand Down Expand Up @@ -153,35 +157,70 @@ export const generateChannelHighlight = async ({
excludedSourceIds,
});
const availablePosts = mergePosts([basePosts, relationPosts]);
const liveHighlights = canonicalizeCurrentHighlights({
highlights: activeHighlights,
relations,
posts: availablePosts,
const inaccessiblePostIds = new Set(
availablePosts
.filter((post) => post.sourceId === UNKNOWN_SOURCE)
.map((post) => post.id),
);
const fallbackPostIds = await fetchPublicShareFallbackPostIds({
con,
sharedPostIds: [
...new Set([
...availablePosts.map((post) => post.id),
...retiredHighlightPostIds,
]),
],
excludedSourceIds,
});
const liveHighlights = applyPublicShareFallbackToHighlights({
highlights: canonicalizeCurrentHighlights({
highlights: activeHighlights,
relations,
posts: availablePosts,
}),
inaccessiblePostIds,
fallbackPostIds,
});
const evaluationHighlights = canonicalizeCurrentHighlights({
highlights: evaluationHistoryHighlights.map(toHighlightItem),
relations,
posts: availablePosts,
const evaluationHighlights = applyPublicShareFallbackToHighlights({
highlights: canonicalizeCurrentHighlights({
highlights: evaluationHistoryHighlights.map(toHighlightItem),
relations,
posts: availablePosts,
}),
inaccessiblePostIds,
fallbackPostIds,
});
const retiredEvaluationHighlights = canonicalizeCurrentHighlights({
highlights: evaluationHistoryHighlights
.filter((item) => !!item.retiredAt)
.map(toHighlightItem),
relations,
posts: availablePosts,
const retiredEvaluationHighlights = applyPublicShareFallbackToHighlights({
highlights: canonicalizeCurrentHighlights({
highlights: evaluationHistoryHighlights
.filter((item) => !!item.retiredAt)
.map(toHighlightItem),
relations,
posts: availablePosts,
}),
inaccessiblePostIds,
fallbackPostIds,
});

const currentHighlightPostIds = new Set(
liveHighlights.map((item) => item.postId),
);
const retiredHighlightPostIdSet = new Set(retiredHighlightPostIds);
const retiredHighlightPostIdSet = new Set(
retiredHighlightPostIds.map(
(postId) => fallbackPostIds.get(postId) || postId,
),
);
const retiredEvaluationPostIdSet = new Set(
retiredEvaluationHighlights.map((item) => item.postId),
);
const newCandidates = buildCandidates({
posts: availablePosts,
relations,
horizonStart,
const newCandidates = applyPublicShareFallbackToCandidates({
candidates: buildCandidates({
posts: availablePosts,
relations,
horizonStart,
}),
inaccessiblePostIds,
fallbackPostIds,
}).filter(
(candidate) =>
!currentHighlightPostIds.has(candidate.postId) &&
Expand Down
48 changes: 48 additions & 0 deletions src/common/channelHighlight/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
PostRelation,
PostRelationType,
} from '../../entity/posts/PostRelation';
import { SharePost } from '../../entity/posts/SharePost';
import type { ChannelHighlightDefinition } from '../../entity/ChannelHighlightDefinition';
import type { HighlightPost } from './types';

Expand Down Expand Up @@ -224,6 +225,53 @@ export const fetchRelations = async ({
.getMany();
};

export const fetchPublicShareFallbackPostIds = async ({
con,
sharedPostIds,
excludedSourceIds = [],
}: {
con: DataSource;
sharedPostIds: string[];
excludedSourceIds?: string[];
}): Promise<Map<string, string>> => {
if (!sharedPostIds.length) {
return new Map();
}

const shares = await con
.getRepository(SharePost)
.createQueryBuilder('post')
.where('post."sharedPostId" IN (:...sharedPostIds)', {
sharedPostIds,
})
.andWhere('post.visible = true')
.andWhere('post.deleted = false')
.andWhere('post.banned = false')
.andWhere('post.private = false')
.andWhere('post.showOnFeed = true')
.andWhere(
excludedSourceIds.length
? 'post."sourceId" NOT IN (:...excludedSourceIds)'
: '1=1',
{ excludedSourceIds },
)
.orderBy('post.upvotes', 'DESC')
.addOrderBy('post."createdAt"', 'DESC')
.addOrderBy('post.id', 'DESC')
.getMany();
const fallbackPostIds = new Map<string, string>();

for (const share of shares) {
if (fallbackPostIds.has(share.sharedPostId)) {
continue;
}

fallbackPostIds.set(share.sharedPostId, share.id);
}

return fallbackPostIds;
};

export const mergePosts = (groups: HighlightPost[][]): HighlightPost[] => {
const byId = new Map<string, HighlightPost>();
for (const posts of groups) {
Expand Down
Loading
Loading