Skip to content

Commit d848cc4

Browse files
authored
fix: use public share fallback for unknown highlights (#3776)
1 parent 3ee3135 commit d848cc4

4 files changed

Lines changed: 350 additions & 21 deletions

File tree

__tests__/workers/generateChannelHighlight.ts

Lines changed: 180 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ import createOrGetConnection from '../../src/db';
33
import { ChannelDigest } from '../../src/entity/ChannelDigest';
44
import { ChannelHighlightDefinition } from '../../src/entity/ChannelHighlightDefinition';
55
import { ChannelHighlightRun } from '../../src/entity/ChannelHighlightRun';
6-
import { AGENTS_DIGEST_SOURCE } from '../../src/entity/Source';
6+
import { AGENTS_DIGEST_SOURCE, UNKNOWN_SOURCE } from '../../src/entity/Source';
77
import {
88
PostHighlight,
99
PostHighlightSignificance,
1010
} from '../../src/entity/PostHighlight';
11-
import { ArticlePost, CollectionPost, Source } from '../../src/entity';
11+
import {
12+
ArticlePost,
13+
CollectionPost,
14+
SharePost,
15+
Source,
16+
} from '../../src/entity';
1217
import {
1318
PostRelation,
1419
PostRelationType,
@@ -102,6 +107,41 @@ const saveCollection = async ({
102107
},
103108
});
104109

110+
const saveShare = async ({
111+
id,
112+
sharedPostId,
113+
createdAt,
114+
sourceId = 'content-source',
115+
title = 'Shared story',
116+
upvotes = 0,
117+
private: isPrivate = false,
118+
}: {
119+
id: string;
120+
sharedPostId: string;
121+
createdAt: Date;
122+
sourceId?: string;
123+
title?: string;
124+
upvotes?: number;
125+
private?: boolean;
126+
}) =>
127+
con.getRepository(SharePost).save({
128+
id,
129+
shortId: id,
130+
title,
131+
sourceId,
132+
sharedPostId,
133+
createdAt,
134+
metadataChangedAt: createdAt,
135+
statsUpdatedAt: createdAt,
136+
visible: true,
137+
deleted: false,
138+
banned: false,
139+
private: isPrivate,
140+
showOnFeed: true,
141+
upvotes,
142+
type: PostType.Share,
143+
});
144+
105145
describe('generateChannelHighlight worker', () => {
106146
beforeEach(async () => {
107147
await con
@@ -721,6 +761,144 @@ describe('generateChannelHighlight worker', () => {
721761
]);
722762
});
723763

764+
it('should replace unknown-source candidates with the most upvoted public share before evaluation', async () => {
765+
const now = new Date('2026-03-03T12:40:00.000Z');
766+
await con.getRepository(ChannelHighlightDefinition).save({
767+
channel: 'vibes',
768+
mode: 'publish',
769+
candidateHorizonHours: 72,
770+
maxItems: 3,
771+
});
772+
await con
773+
.getRepository(Source)
774+
.save(
775+
createSource(
776+
UNKNOWN_SOURCE,
777+
'Unknown',
778+
'https://daily.dev/unknown.png',
779+
undefined,
780+
true,
781+
),
782+
);
783+
await saveArticle({
784+
id: 'unk-orig-1',
785+
sourceId: UNKNOWN_SOURCE,
786+
title: 'Unknown source story',
787+
createdAt: new Date('2026-03-03T12:20:00.000Z'),
788+
});
789+
await saveShare({
790+
id: 'pub-share-1',
791+
sharedPostId: 'unk-orig-1',
792+
createdAt: new Date('2026-03-03T12:26:00.000Z'),
793+
upvotes: 25,
794+
});
795+
await saveShare({
796+
id: 'pub-share-2',
797+
sharedPostId: 'unk-orig-1',
798+
createdAt: new Date('2026-03-03T12:25:00.000Z'),
799+
upvotes: 50,
800+
});
801+
await saveShare({
802+
id: 'priv-share1',
803+
sharedPostId: 'unk-orig-1',
804+
createdAt: new Date('2026-03-03T12:27:00.000Z'),
805+
upvotes: 100,
806+
private: true,
807+
});
808+
809+
const evaluatorSpy = jest
810+
.spyOn(evaluator, 'evaluateChannelHighlights')
811+
.mockImplementation(async ({ newCandidates }) => ({
812+
items: [
813+
{
814+
postId: newCandidates[0].postId,
815+
headline: 'Shared headline',
816+
significanceLabel: 'breaking',
817+
reason: 'test',
818+
},
819+
],
820+
}));
821+
822+
await expectSuccessfulTypedBackground<'api.v1.generate-channel-highlight'>(
823+
worker,
824+
{
825+
channel: 'vibes',
826+
scheduledAt: now.toISOString(),
827+
},
828+
);
829+
830+
expect(evaluatorSpy).toHaveBeenCalledTimes(1);
831+
expect(evaluatorSpy.mock.calls[0][0].newCandidates).toEqual([
832+
expect.objectContaining({
833+
postId: 'pub-share-2',
834+
title: 'Unknown source story',
835+
}),
836+
]);
837+
838+
const liveHighlights = await con.getRepository(PostHighlight).find({
839+
where: { channel: 'vibes', retiredAt: IsNull() },
840+
});
841+
expect(liveHighlights).toEqual([
842+
expect.objectContaining({
843+
postId: 'pub-share-2',
844+
headline: 'Shared headline',
845+
significance: PostHighlightSignificance.Breaking,
846+
reason: 'test',
847+
}),
848+
]);
849+
});
850+
851+
it('should skip unknown-source candidates when no public share exists', async () => {
852+
const now = new Date('2026-03-03T12:41:00.000Z');
853+
await con.getRepository(ChannelHighlightDefinition).save({
854+
channel: 'vibes',
855+
mode: 'publish',
856+
candidateHorizonHours: 72,
857+
maxItems: 3,
858+
});
859+
await con
860+
.getRepository(Source)
861+
.save(
862+
createSource(
863+
UNKNOWN_SOURCE,
864+
'Unknown',
865+
'https://daily.dev/unknown.png',
866+
undefined,
867+
true,
868+
),
869+
);
870+
await saveArticle({
871+
id: 'unk-orig-2',
872+
sourceId: UNKNOWN_SOURCE,
873+
title: 'Unknown source story 2',
874+
createdAt: new Date('2026-03-03T12:21:00.000Z'),
875+
});
876+
await saveShare({
877+
id: 'priv-share2',
878+
sharedPostId: 'unk-orig-2',
879+
createdAt: new Date('2026-03-03T12:28:00.000Z'),
880+
upvotes: 100,
881+
private: true,
882+
});
883+
884+
const evaluatorSpy = jest.spyOn(evaluator, 'evaluateChannelHighlights');
885+
886+
await expectSuccessfulTypedBackground<'api.v1.generate-channel-highlight'>(
887+
worker,
888+
{
889+
channel: 'vibes',
890+
scheduledAt: now.toISOString(),
891+
},
892+
);
893+
894+
expect(evaluatorSpy).not.toHaveBeenCalled();
895+
896+
const liveHighlights = await con.getRepository(PostHighlight).find({
897+
where: { channel: 'vibes', retiredAt: IsNull() },
898+
});
899+
expect(liveHighlights).toEqual([]);
900+
});
901+
724902
it('should exclude posts refreshed only by stats updates from incremental candidates', async () => {
725903
const now = new Date('2026-03-03T12:45:00.000Z');
726904
await con.getRepository(ChannelHighlightDefinition).save({

src/common/channelHighlight/generate.ts

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { DataSource } from 'typeorm';
22
import { logger as baseLogger } from '../../logger';
33
import { ChannelHighlightDefinition } from '../../entity/ChannelHighlightDefinition';
44
import { ChannelHighlightRun } from '../../entity/ChannelHighlightRun';
5+
import { UNKNOWN_SOURCE } from '../../entity/Source';
56
import { getChannelDigestSourceIds } from '../channelDigest/definitions';
67
import { compareSnapshots } from './decisions';
78
import { evaluateChannelHighlights } from './evaluate';
@@ -11,6 +12,7 @@ import {
1112
fetchEvaluationHistoryHighlights,
1213
fetchIncrementalPosts,
1314
fetchPostsByIds,
15+
fetchPublicShareFallbackPostIds,
1416
fetchRetiredHighlightPostIds,
1517
fetchRelations,
1618
getEvaluationHistoryStart,
@@ -19,6 +21,8 @@ import {
1921
mergePosts,
2022
} from './queries';
2123
import {
24+
applyPublicShareFallbackToCandidates,
25+
applyPublicShareFallbackToHighlights,
2226
buildCandidates,
2327
canonicalizeCurrentHighlights,
2428
toHighlightItem,
@@ -153,35 +157,70 @@ export const generateChannelHighlight = async ({
153157
excludedSourceIds,
154158
});
155159
const availablePosts = mergePosts([basePosts, relationPosts]);
156-
const liveHighlights = canonicalizeCurrentHighlights({
157-
highlights: activeHighlights,
158-
relations,
159-
posts: availablePosts,
160+
const inaccessiblePostIds = new Set(
161+
availablePosts
162+
.filter((post) => post.sourceId === UNKNOWN_SOURCE)
163+
.map((post) => post.id),
164+
);
165+
const fallbackPostIds = await fetchPublicShareFallbackPostIds({
166+
con,
167+
sharedPostIds: [
168+
...new Set([
169+
...availablePosts.map((post) => post.id),
170+
...retiredHighlightPostIds,
171+
]),
172+
],
173+
excludedSourceIds,
174+
});
175+
const liveHighlights = applyPublicShareFallbackToHighlights({
176+
highlights: canonicalizeCurrentHighlights({
177+
highlights: activeHighlights,
178+
relations,
179+
posts: availablePosts,
180+
}),
181+
inaccessiblePostIds,
182+
fallbackPostIds,
160183
});
161-
const evaluationHighlights = canonicalizeCurrentHighlights({
162-
highlights: evaluationHistoryHighlights.map(toHighlightItem),
163-
relations,
164-
posts: availablePosts,
184+
const evaluationHighlights = applyPublicShareFallbackToHighlights({
185+
highlights: canonicalizeCurrentHighlights({
186+
highlights: evaluationHistoryHighlights.map(toHighlightItem),
187+
relations,
188+
posts: availablePosts,
189+
}),
190+
inaccessiblePostIds,
191+
fallbackPostIds,
165192
});
166-
const retiredEvaluationHighlights = canonicalizeCurrentHighlights({
167-
highlights: evaluationHistoryHighlights
168-
.filter((item) => !!item.retiredAt)
169-
.map(toHighlightItem),
170-
relations,
171-
posts: availablePosts,
193+
const retiredEvaluationHighlights = applyPublicShareFallbackToHighlights({
194+
highlights: canonicalizeCurrentHighlights({
195+
highlights: evaluationHistoryHighlights
196+
.filter((item) => !!item.retiredAt)
197+
.map(toHighlightItem),
198+
relations,
199+
posts: availablePosts,
200+
}),
201+
inaccessiblePostIds,
202+
fallbackPostIds,
172203
});
173204

174205
const currentHighlightPostIds = new Set(
175206
liveHighlights.map((item) => item.postId),
176207
);
177-
const retiredHighlightPostIdSet = new Set(retiredHighlightPostIds);
208+
const retiredHighlightPostIdSet = new Set(
209+
retiredHighlightPostIds.map(
210+
(postId) => fallbackPostIds.get(postId) || postId,
211+
),
212+
);
178213
const retiredEvaluationPostIdSet = new Set(
179214
retiredEvaluationHighlights.map((item) => item.postId),
180215
);
181-
const newCandidates = buildCandidates({
182-
posts: availablePosts,
183-
relations,
184-
horizonStart,
216+
const newCandidates = applyPublicShareFallbackToCandidates({
217+
candidates: buildCandidates({
218+
posts: availablePosts,
219+
relations,
220+
horizonStart,
221+
}),
222+
inaccessiblePostIds,
223+
fallbackPostIds,
185224
}).filter(
186225
(candidate) =>
187226
!currentHighlightPostIds.has(candidate.postId) &&

src/common/channelHighlight/queries.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
PostRelation,
1414
PostRelationType,
1515
} from '../../entity/posts/PostRelation';
16+
import { SharePost } from '../../entity/posts/SharePost';
1617
import type { ChannelHighlightDefinition } from '../../entity/ChannelHighlightDefinition';
1718
import type { HighlightPost } from './types';
1819

@@ -224,6 +225,53 @@ export const fetchRelations = async ({
224225
.getMany();
225226
};
226227

228+
export const fetchPublicShareFallbackPostIds = async ({
229+
con,
230+
sharedPostIds,
231+
excludedSourceIds = [],
232+
}: {
233+
con: DataSource;
234+
sharedPostIds: string[];
235+
excludedSourceIds?: string[];
236+
}): Promise<Map<string, string>> => {
237+
if (!sharedPostIds.length) {
238+
return new Map();
239+
}
240+
241+
const shares = await con
242+
.getRepository(SharePost)
243+
.createQueryBuilder('post')
244+
.where('post."sharedPostId" IN (:...sharedPostIds)', {
245+
sharedPostIds,
246+
})
247+
.andWhere('post.visible = true')
248+
.andWhere('post.deleted = false')
249+
.andWhere('post.banned = false')
250+
.andWhere('post.private = false')
251+
.andWhere('post.showOnFeed = true')
252+
.andWhere(
253+
excludedSourceIds.length
254+
? 'post."sourceId" NOT IN (:...excludedSourceIds)'
255+
: '1=1',
256+
{ excludedSourceIds },
257+
)
258+
.orderBy('post.upvotes', 'DESC')
259+
.addOrderBy('post."createdAt"', 'DESC')
260+
.addOrderBy('post.id', 'DESC')
261+
.getMany();
262+
const fallbackPostIds = new Map<string, string>();
263+
264+
for (const share of shares) {
265+
if (fallbackPostIds.has(share.sharedPostId)) {
266+
continue;
267+
}
268+
269+
fallbackPostIds.set(share.sharedPostId, share.id);
270+
}
271+
272+
return fallbackPostIds;
273+
};
274+
227275
export const mergePosts = (groups: HighlightPost[][]): HighlightPost[] => {
228276
const byId = new Map<string, HighlightPost>();
229277
for (const posts of groups) {

0 commit comments

Comments
 (0)