fix: reconcile physique targeting for queued posts
This commit is contained in:
1 parent
d0a8bd0e2f
commit
d8d635e1ee
10 files changed
+250
-12
No files matched your search
@@ -26,13 +26,13 @@ function post(id: string, author: string, created = 100): RedditPost {
|
||||
};
|
||||
}
|
||||
|
||||
function prepared(candidate: RedditPost): ProcessedPost {
|
||||
function prepared(candidate: RedditPost, physiqueScore: number | null = 5): ProcessedPost {
|
||||
return {
|
||||
post: candidate,
|
||||
imageAnalysis: 'analysis',
|
||||
detectedGender: 'female',
|
||||
attractivenessScore: 64,
|
||||
physiqueScore: 5,
|
||||
physiqueScore,
|
||||
physiqueTargeting: { enabled: true, minScore: 3, maxScore: 7 },
|
||||
sourceImageUrl: 'https://i.redd.it/example.jpg',
|
||||
generatedMessage: 'prepared message',
|
||||
@@ -180,6 +180,56 @@ describe('CandidateQueueRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reconciles ready and retryable images when the physique range changes', () => {
|
||||
queue.enqueue([
|
||||
post('inside', 'alice', 300),
|
||||
post('outside', 'bob', 200),
|
||||
post('retry', 'carol', 100),
|
||||
], 'image', 1, 1_000);
|
||||
|
||||
for (const worker of ['screen-1', 'screen-2', 'screen-3']) {
|
||||
const candidate = queue.claimForScreening(worker, 100, 1_010)!;
|
||||
const score = candidate.postId === 'outside' ? 7 : 4;
|
||||
queue.markReady(candidate.postId, worker, prepared(candidate.post, score), 1_011);
|
||||
}
|
||||
const retry = queue.claimReady('account-a', 'send', 100, 1_012)!;
|
||||
expect(retry.postId).toBe('inside');
|
||||
queue.deferDelivery(retry.postId, 'send', 'rate limited', 2_000, 1_013);
|
||||
|
||||
expect(queue.reconcilePhysiqueTargeting(
|
||||
{ enabled: true, minScore: 1, maxScore: 6 },
|
||||
1_020,
|
||||
)).toEqual({ retained: 2, rejected: 1 });
|
||||
|
||||
const active = queue.listActive();
|
||||
expect(active.map((candidate) => candidate.postId)).toEqual(['inside', 'retry']);
|
||||
expect(active.every((candidate) => (
|
||||
candidate.prepared?.physiqueTargeting?.enabled === true
|
||||
&& candidate.prepared.physiqueTargeting.maxScore === 6
|
||||
))).toBe(true);
|
||||
expect(queue.getStats().byStatus.rejected).toBe(1);
|
||||
});
|
||||
|
||||
it('fails closed for an unscored prepared image but does not touch a leased send', () => {
|
||||
queue.enqueue([
|
||||
post('unscored', 'alice', 200),
|
||||
post('sending', 'bob', 100),
|
||||
], 'image', 1, 1_000);
|
||||
for (const worker of ['screen-1', 'screen-2']) {
|
||||
const candidate = queue.claimForScreening(worker, 100, 1_010)!;
|
||||
queue.markReady(candidate.postId, worker, prepared(candidate.post, null), 1_011);
|
||||
}
|
||||
const sending = queue.claimReady('account-a', 'send', 100, 1_012)!;
|
||||
|
||||
expect(queue.reconcilePhysiqueTargeting(
|
||||
{ enabled: true, minScore: 1, maxScore: 6 },
|
||||
1_020,
|
||||
)).toEqual({ retained: 0, rejected: 1 });
|
||||
expect(queue.listActive()).toEqual([
|
||||
expect.objectContaining({ postId: sending.postId, status: 'sending' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists only active candidates in newest-post order across stages', () => {
|
||||
queue.enqueue([
|
||||
post('waiting', 'alice', 100),
|
||||
|
||||
@@ -357,6 +357,58 @@ describe('RedditApiClient', () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a configured seller platform in structured social links', async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
subreddit: { public_description: 'Just me', over_18: true },
|
||||
social_links: [{ title: 'My OnlyFans', outbound_url: 'https://onlyfans.com/example' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(axios.create).mockReturnValue({ get } as any);
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
|
||||
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
|
||||
.resolves.toBe(true);
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('detects a narrow NSFW link-to-more funnel when Reddit omits the social link', async () => {
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
subreddit: {
|
||||
public_description: "Hit the link to see more of me, we both know why you're here",
|
||||
title: '',
|
||||
display_name_prefixed: 'u/testuser',
|
||||
over_18: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(axios.create).mockReturnValue({ get } as any);
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
|
||||
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
|
||||
.resolves.toBe(true);
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not treat an ordinary or non-NSFW profile link as a seller funnel', async () => {
|
||||
const get = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
data: { data: { subreddit: { public_description: 'Hit the link to see more of my art', over_18: false } } },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { data: { children: [] } } });
|
||||
vi.mocked(axios.create).mockReturnValue({ get } as any);
|
||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||
|
||||
await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com']))
|
||||
.resolves.toBe(false);
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns false when no keywords match', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
|
||||
@@ -25,10 +25,11 @@ function harness() {
|
||||
// harnesses would otherwise share the same `conversations` instance.
|
||||
const config = structuredClone(ConfigSchema.parse(getTestConfig())) as Config;
|
||||
const saved: Config[] = [];
|
||||
const candidateQueue = { reconcilePhysiqueTargeting: vi.fn(() => ({ retained: 2, rejected: 1 })) };
|
||||
const ctx = {
|
||||
config,
|
||||
runtimeConfig: config,
|
||||
services: { configStore: { save: (next: Config) => saved.push(next) } },
|
||||
services: { configStore: { save: (next: Config) => saved.push(next) }, candidateQueue },
|
||||
getOnConfigChange: () => undefined,
|
||||
activity: { push: vi.fn() },
|
||||
broadcaster: { push: vi.fn() },
|
||||
@@ -37,7 +38,7 @@ function harness() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api', createConfigRouter(ctx));
|
||||
return { app, config, saved };
|
||||
return { app, config, saved, candidateQueue, ctx };
|
||||
}
|
||||
|
||||
describe('config route conversation kill controls', () => {
|
||||
@@ -112,7 +113,7 @@ describe('config route conversation kill controls', () => {
|
||||
|
||||
describe('config route physique targeting', () => {
|
||||
it('hot-updates and persists the image-candidate score range', async () => {
|
||||
const { app, config, saved } = harness();
|
||||
const { app, config, saved, candidateQueue, ctx } = harness();
|
||||
const targetingRef = config.reddit.physiqueTargeting;
|
||||
|
||||
const response = await request(app, '/api/config', {
|
||||
@@ -129,6 +130,13 @@ describe('config route physique targeting', () => {
|
||||
expect(config.reddit.physiqueTargeting).toBe(targetingRef);
|
||||
expect(config.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 });
|
||||
expect(saved.at(-1)?.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 });
|
||||
expect(candidateQueue.reconcilePhysiqueTargeting).toHaveBeenCalledWith({
|
||||
enabled: true, minScore: 3, maxScore: 7,
|
||||
});
|
||||
expect(ctx.activity.push).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'Physique targeting reconciled 2 prepared image candidate(s) and rejected 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an inverted physique range without mutating configuration', async () => {
|
||||
|
||||
Reference in new issue
Block a user