fix: enforce current physique range in funnel

This commit is contained in:
jjbrunton committed 2026-08-10 18:35:22 +01:00
1 parent d8d635e1ee
commit 1515b3e291
12 files changed
+92 -13

No files matched your search

@@ -38,7 +38,29 @@ function formatWaitTime(timestamp: number, now: number): string {
return `in ${Math.ceil(remaining / 86_400_000)}d`; return `in ${Math.ceil(remaining / 86_400_000)}d`;
} }
function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate }) { type PhysiqueTargeting = { enabled: boolean; minScore: number; maxScore: number };
function targetingLabel(current: PhysiqueTargeting, prepared: PhysiqueTargeting | null): string {
if (current.enabled) {
const currentLabel = `Current target ${current.minScore}–${current.maxScore}`;
if (!prepared?.enabled) return `${currentLabel} · Prepared with filter off`;
if (prepared.minScore !== current.minScore || prepared.maxScore !== current.maxScore) {
return `${currentLabel} · Prepared for ${prepared.minScore}–${prepared.maxScore}`;
}
return currentLabel;
}
return prepared?.enabled
? `Current filter off · Prepared for ${prepared.minScore}–${prepared.maxScore}`
: 'Current filter off';
}
function PreparedCandidateDetails({
candidate,
currentTargeting,
}: {
candidate: OutreachCandidate;
currentTargeting: PhysiqueTargeting;
}) {
const preparation = candidate.preparation; const preparation = candidate.preparation;
if (!preparation) return null; if (!preparation) return null;
const targeting = preparation.physiqueTargeting; const targeting = preparation.physiqueTargeting;
@@ -61,7 +83,7 @@ function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate
<div className="candidate-preparation-copy"> <div className="candidate-preparation-copy">
<div className="candidate-score-grid" aria-label="Preparation scoring"> <div className="candidate-score-grid" aria-label="Preparation scoring">
<div><span>Attractiveness</span><strong>{preparation.attractivenessScore === null ? 'Not scored' : `${preparation.attractivenessScore}/100`}</strong><small>Used in the opener prompt</small></div> <div><span>Attractiveness</span><strong>{preparation.attractivenessScore === null ? 'Not scored' : `${preparation.attractivenessScore}/100`}</strong><small>Used in the opener prompt</small></div>
<div><span>Physique</span><strong>{preparation.physiqueScore === null ? 'Not scorable' : `${preparation.physiqueScore}/10`}</strong><small>{targeting?.enabled ? `Target ${targeting.minScore}–${targeting.maxScore}` : 'Range filter off'}</small></div> <div><span>Physique</span><strong>{preparation.physiqueScore === null ? 'Not scorable' : `${preparation.physiqueScore}/10`}</strong><small>{targetingLabel(currentTargeting, targeting)}</small></div>
<div><span>Detected gender</span><strong>{preparation.detectedGender}</strong><small>Image analysis</small></div> <div><span>Detected gender</span><strong>{preparation.detectedGender}</strong><small>Image analysis</small></div>
</div> </div>
<div className="candidate-preparation-metadata"> <div className="candidate-preparation-metadata">
@@ -84,7 +106,15 @@ function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate
); );
} }
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) { function CandidateCard({
candidate,
currentTargeting,
now,
}: {
candidate: OutreachCandidate;
currentTargeting: PhysiqueTargeting;
now: number;
}) {
const waitingUntil = candidate.availableAt > now; const waitingUntil = candidate.availableAt > now;
const isKnownStatus = KNOWN_STATUSES.has(candidate.status); const isKnownStatus = KNOWN_STATUSES.has(candidate.status);
return ( return (
@@ -114,7 +144,7 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
</div> </div>
{candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null} {candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null}
{candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null} {candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null}
<PreparedCandidateDetails candidate={candidate} /> <PreparedCandidateDetails candidate={candidate} currentTargeting={currentTargeting} />
</article> </article>
</li> </li>
); );
@@ -200,7 +230,14 @@ export function OutreachPage() {
<div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div> <div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div>
) : ( ) : (
<ol aria-label="Active outreach candidates" className="outreach-candidates"> <ol aria-label="Active outreach candidates" className="outreach-candidates">
{data.candidates.map((candidate) => <CandidateCard candidate={candidate} key={candidate.postId} now={now} />)} {data.candidates.map((candidate) => (
<CandidateCard
candidate={candidate}
currentTargeting={data.physiqueTargeting}
key={candidate.postId}
now={now}
/>
))}
</ol> </ol>
)} )}
</section> </section>
+1 -1
View File
@@ -36,7 +36,7 @@ A screening worker leases one discovered candidate. `MonitorService.screenCandid
3. Seller evidence from the post, profile/about (including structured social links when exposed and narrow NSFW link-funnel language), and recent activity. 3. Seller evidence from the post, profile/about (including structured social links when exposed and narrow NSFW link-funnel language), and recent activity.
4. Source-specific preparation in `ProcessorService`. 4. Source-specific preparation in `ProcessorService`.
For image posts, preparation downloads and validates the image, detects target gender, and performs model analysis. The analysis includes a separate 1–10 visible-physique score only when enough of the body is visible. If physique targeting is enabled, an unknown score or a score outside the configured inclusive range becomes an explicit rejection before strategy selection and opener generation. Passing image posts then select an image strategy and generate an opener. Chat posts do not use physique targeting; they select a chat strategy and generate from the post text. Both paths apply outbound safety checks and return either a prepared immutable result or an explicit skip reason. For image posts, preparation downloads and validates the image, detects target gender, and performs model analysis. The analysis includes a separate 1–10 visible-physique score only when enough of the body is visible. Its strict anchors place a broadly average visible physique at 5, reserve 8–10 for rare athletic or model-like physiques, and explicitly forbid using 6 as a safe default; nudity, breast size, face, pose, and photographic quality do not affect it. If physique targeting is enabled, an unknown score or a score outside the configured inclusive range becomes an explicit rejection before strategy selection and opener generation. Passing image posts then select an image strategy and generate an opener. Chat posts do not use physique targeting; they select a chat strategy and generate from the post text. Both paths apply outbound safety checks and return either a prepared immutable result or an explicit skip reason.
Terminal results become rejected candidates. Rate limits and transient failures become retryable candidates. Prepared candidates become ready; the persisted snapshot includes the opener, grounded image analysis, attractiveness and physique scores, the physique range used, source-image URL, and experiment/model/tone metadata. This lets a different account deliver without repeating AI work and lets the authenticated operator inspect the exact preparation context. Saving a physique-range change transactionally re-evaluates prepared image candidates in ready and delivery-retry stages, rejects those outside the new range, and refreshes the displayed range snapshot for retained candidates. A candidate already leased for sending is left unchanged until that send resolves. Terminal results become rejected candidates. Rate limits and transient failures become retryable candidates. Prepared candidates become ready; the persisted snapshot includes the opener, grounded image analysis, attractiveness and physique scores, the physique range used, source-image URL, and experiment/model/tone metadata. This lets a different account deliver without repeating AI work and lets the authenticated operator inspect the exact preparation context. Saving a physique-range change transactionally re-evaluates prepared image candidates in ready and delivery-retry stages, rejects those outside the new range, and refreshes the displayed range snapshot for retained candidates. A candidate already leased for sending is left unchanged until that send resolves.
+1 -1
View File
@@ -25,7 +25,7 @@ Navigation is declared in `app/navigation.ts`; route elements are composed in `a
The Monitoring settings section owns the optional physique-targeting toggle and inclusive 1–10 range. It explains that the signal is subjective, applies only to image posts, and screens out unscorable images while enabled. The Monitoring settings section owns the optional physique-targeting toggle and inclusive 1–10 range. It explains that the signal is subjective, applies only to image posts, and screens out unscorable images while enabled.
Prepared cards in the authenticated outreach funnel expose the exact opener, grounded image analysis, attractiveness and physique scores, target range, detected gender, strategy/tone/model metadata, and original source image. The source image uses the shared `OriginPostCard` privacy behavior: blurred initially, first click reveals it, and a later click opens the full-screen preview. Candidates from older persisted snapshots degrade cleanly when newer optional metadata is absent. Prepared cards in the authenticated outreach funnel expose the exact opener, grounded image analysis, attractiveness and physique scores, detected gender, strategy/tone/model metadata, and original source image. Physique scoring shows both the currently configured target and, when different, the range snapshot used during preparation; it does not label a historical snapshot as though it were the live setting. The source image uses the shared `OriginPostCard` privacy behavior: blurred initially, first click reveals it, and a later click opens the full-screen preview. Candidates from older persisted snapshots degrade cleanly when newer optional metadata is absent.
## State model ## State model
+1 -1
View File
@@ -30,7 +30,7 @@ Configured subreddits have a source type:
- `image`: image-led posts are downloaded and analyzed before an opener is generated. - `image`: image-led posts are downloaded and analyzed before an opener is generated.
- `chat`: text-led posts require a parseable self-declared gender and adult age, then use the chat-opener prompt. - `chat`: text-led posts require a parseable self-declared gender and adult age, then use the chat-opener prompt.
Image analysis may produce a subjective 1–10 visible-physique score. It is not a medical assessment and remains unknown when the image does not show enough of the body for a grounded judgment. Physique targeting is disabled by default. When an operator enables an inclusive minimum/maximum range, image candidates outside that range—and unscorable image candidates—are rejected before opener generation; chat candidates are unaffected. Image analysis may produce a subjective 1–10 visible-physique score. It is not a medical assessment and remains unknown when the image does not show enough of the body for a grounded judgment. The scale is deliberately critical: 5 is broadly average, 6 requires visible above-average leanness or definition, and 8–10 are reserved for rare athletic or model-like physiques. Nudity and photographic presentation do not increase the score. Physique targeting is disabled by default. When an operator enables an inclusive minimum/maximum range, image candidates outside that range—and unscorable image candidates—are rejected before opener generation; chat candidates are unaffected.
Seller filtering combines the candidate post with Reddit profile/about, structured social links when Reddit exposes them, and recent activity evidence. Because Reddit's legacy profile response can omit a social-link button that is visible in its UI, an NSFW bio is also rejected when it combines an explicit link call-to-action with a promise of more or exclusive personal content. The default policy treats Telegram names and links as seller signals alongside paid-content platforms and payment prompts. The configured keyword/domain lists are policy inputs, not a substitute for the surrounding screening flow. A profile lookup failure is not itself proof that a person is a seller; network and rate-limit failures must remain distinguishable from a rejection. Seller filtering combines the candidate post with Reddit profile/about, structured social links when Reddit exposes them, and recent activity evidence. Because Reddit's legacy profile response can omit a social-link button that is visible in its UI, an NSFW bio is also rejected when it combines an explicit link call-to-action with a promise of more or exclusive personal content. The default policy treats Telegram names and links as seller signals alongside paid-content platforms and payment prompts. The configured keyword/domain lists are policy inputs, not a substitute for the surrounding screening flow. A profile lookup failure is not itself proof that a person is a seller; network and rate-limit failures must remain distinguishable from a rejection.
+5
View File
@@ -61,6 +61,11 @@ export const OutreachCandidateSchema = z.strictObject({
export const OutreachFunnelResponseSchema = z.strictObject({ export const OutreachFunnelResponseSchema = z.strictObject({
generatedAt: z.number(), generatedAt: z.number(),
physiqueTargeting: z.strictObject({
enabled: z.boolean(),
minScore: z.number().int().min(1).max(10),
maxScore: z.number().int().min(1).max(10),
}),
activeCount: z.number().int().nonnegative(), activeCount: z.number().int().nonnegative(),
stages: OutreachStageCountsSchema, stages: OutreachStageCountsSchema,
outcomes: z.strictObject({ outcomes: z.strictObject({
+13 -1
View File
@@ -372,7 +372,18 @@ Use every applicable tag; tags may overlap. Set safety to "nsfw" when nudity, ex
const startedAt = Date.now(); const startedAt = Date.now();
const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown
On the second line, assess only the directly visible physique. Output exactly PHYSIQUE_SCORE:<number> using an integer from 1 to 10, or PHYSIQUE_SCORE:UNKNOWN when the torso and enough of the body are not visible for a grounded assessment. Do not infer physique from a face, a close crop, clothing that obscures body shape, or off-frame anatomy. This is not a medical diagnosis: 1 means a visibly very large-bodied physique, 5 means a broadly average physique, and 10 means an exceptionally lean, toned, model-like physique. Ignore facial attractiveness, photography quality, pose, and setting. On the second line, assess only the directly visible physique. Output exactly PHYSIQUE_SCORE:<number> using an integer from 1 to 10, or PHYSIQUE_SCORE:UNKNOWN when the torso and enough of the body are not visible for a grounded assessment. Do not infer physique from a face, a close crop, clothing that obscures body shape, or off-frame anatomy. This is not a medical diagnosis. Apply this strict comparative scale:
- 1 = exceptionally large-bodied
- 2 = very large-bodied
- 3 = clearly large-bodied with substantial visible softness
- 4 = somewhat large-bodied or noticeably soft
- 5 = broadly average, with neither notable leanness nor notable size
- 6 = somewhat leaner or more defined than average
- 7 = clearly lean, proportionate, or toned
- 8 = distinctly athletic or model-like
- 9 = exceptional visible conditioning and proportions
- 10 = extraordinarily rare, elite model or competition-level physique
Use the full scale and be highly critical. Scores 8-10 must be rare. Do not use 6 as a safe default: if the evidence does not clearly exceed average, score 5 or below. Judge each image against the anchors, not against other images in the batch. Ignore facial attractiveness, nudity, breast size, photography quality, pose, and setting.
Then provide your analysis: Then provide your analysis:
@@ -400,6 +411,7 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
}, },
], ],
max_tokens: 600, max_tokens: 600,
temperature: 0,
}); });
} catch (error) { } catch (error) {
this.recordError('analyzeImage', model, startedAt, error); this.recordError('analyzeImage', model, startedAt, error);
+6 -1
View File
@@ -292,7 +292,12 @@ export class CandidateQueueRepository {
...row.prepared, ...row.prepared,
physiqueTargeting: { ...targeting }, physiqueTargeting: { ...targeting },
}; };
result.retained += retain.run(JSON.stringify(refreshed), now, row.postId).changes; result.retained += 1;
const previous = row.prepared.physiqueTargeting;
const snapshotMatches = previous?.enabled === targeting.enabled
&& previous.minScore === targeting.minScore
&& previous.maxScore === targeting.maxScore;
if (!snapshotMatches) retain.run(JSON.stringify(refreshed), now, row.postId);
} }
return result; return result;
})(); })();
+2 -1
View File
@@ -643,8 +643,9 @@ async function runConcurrentAccountRuntimes(app: AppContainer): Promise<void> {
continue; continue;
} }
const target = candidateFunnelTarget(); const target = candidateFunnelTarget();
if (target > 0) candidateQueueRepo.promoteDueRetries(target);
candidateQueueRepo.recoverExpiredLeases(); candidateQueueRepo.recoverExpiredLeases();
candidateQueueRepo.reconcilePhysiqueTargeting(config.reddit.physiqueTargeting);
if (target > 0) candidateQueueRepo.promoteDueRetries(target);
app.recipientDeduplicator.recoverStaleReservations(Date.now() - 10 * 60_000); app.recipientDeduplicator.recoverStaleReservations(Date.now() - 10 * 60_000);
const recipients = await availableRecipients(); const recipients = await availableRecipients();
const recipient = recipients[0]; const recipient = recipients[0];
+1
View File
@@ -112,6 +112,7 @@ export function createOutreachRouter(ctx: WebContext): Router {
}; };
const response = { const response = {
generatedAt: Date.now(), generatedAt: Date.now(),
physiqueTargeting: { ...ctx.runtimeConfig.reddit.physiqueTargeting },
activeCount: ACTIVE_STATUSES.reduce((total, status) => total + stats.byStatus[status], 0), activeCount: ACTIVE_STATUSES.reduce((total, status) => total + stats.byStatus[status], 0),
stages, stages,
outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected }, outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected },
+3 -2
View File
@@ -122,6 +122,7 @@ describe('dashboard foundation', () => {
})), })),
http.get('/api/outreach', () => HttpResponse.json({ http.get('/api/outreach', () => HttpResponse.json({
generatedAt: Date.parse('2026-08-09T08:00:00Z'), generatedAt: Date.parse('2026-08-09T08:00:00Z'),
physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 },
activeCount: 3, activeCount: 3,
stages: { discovered: 1, screening: 0, ready: 1, sending: 0, retryable: 1 }, stages: { discovered: 1, screening: 0, ready: 1, sending: 0, retryable: 1 },
outcomes: { sent: 12, rejected: 7 }, outcomes: { sent: 12, rejected: 7 },
@@ -137,7 +138,7 @@ describe('dashboard foundation', () => {
message: 'You look incredible in that pose — what inspired this post?', message: 'You look incredible in that pose — what inspired this post?',
imageAnalysis: 'VISIBLE_FACTS:\nBODY: VISIBLE - full-body pose', imageAnalysis: 'VISIBLE_FACTS:\nBODY: VISIBLE - full-body pose',
detectedGender: 'female', attractivenessScore: 68, physiqueScore: 6, detectedGender: 'female', attractivenessScore: 68, physiqueScore: 6,
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 }, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 },
sourceImageUrl: 'https://i.redd.it/candidate-1.jpg', sourceImageUrl: 'https://i.redd.it/candidate-1.jpg',
strategyId: 'direct-observation', experimentId: 'strategies:direct-observation:abc123', strategyId: 'direct-observation', experimentId: 'strategies:direct-observation:abc123',
promptVersion: 'abc123', selectionReason: 'exploit', modelId: 'example/image-model', promptVersion: 'abc123', selectionReason: 'exploit', modelId: 'example/image-model',
@@ -162,7 +163,7 @@ describe('dashboard foundation', () => {
expect(preparation).toHaveTextContent('You look incredible in that pose'); expect(preparation).toHaveTextContent('You look incredible in that pose');
expect(preparation).toHaveTextContent('68/100'); expect(preparation).toHaveTextContent('68/100');
expect(preparation).toHaveTextContent('6/10'); expect(preparation).toHaveTextContent('6/10');
expect(preparation).toHaveTextContent('Target 4–7'); expect(preparation).toHaveTextContent('Current target 1–6 · Prepared with filter off');
expect(preparation).toHaveTextContent('example/image-model'); expect(preparation).toHaveTextContent('example/image-model');
const sourceImage = within(preparation).getByRole('img', { name: 'Source post: [25F] A potential post' }); const sourceImage = within(preparation).getByRole('img', { name: 'Source post: [25F] A potential post' });
expect(sourceImage).toHaveClass('is-blurred'); expect(sourceImage).toHaveClass('is-blurred');
+15
View File
@@ -335,6 +335,21 @@ describe('OpenRouterClient', () => {
expect(result.description).toBe('Close-cropped portrait.'); expect(result.description).toBe('Close-cropped portrait.');
}); });
it('requests a critical anchored physique score instead of defaulting to 6', async () => {
mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:female\nPHYSIQUE_SCORE:5\n\nDescription.' } }],
});
await client.analyzeImage('base64data', 'image/jpeg');
const request = mockCreate.mock.calls[0][0];
const prompt = request.messages[0].content[0].text;
expect(prompt).toContain('Do not use 6 as a safe default');
expect(prompt).toContain('Scores 8-10 must be rare');
expect(prompt).toContain('5 = broadly average');
expect(request.temperature).toBe(0);
});
it('handles case-insensitive GENDER prefix', async () => { it('handles case-insensitive GENDER prefix', async () => {
mockCreate.mockResolvedValueOnce({ mockCreate.mockResolvedValueOnce({
choices: [{ message: { content: 'GENDER:FEMALE\n\nA woman.' } }], choices: [{ message: { content: 'GENDER:FEMALE\n\nA woman.' } }],
+2
View File
@@ -59,6 +59,7 @@ describe('outreach route', () => {
}]), }]),
}; };
const context = { const context = {
runtimeConfig: { reddit: { physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 } } },
services: { services: {
candidateQueue, candidateQueue,
redditAccounts: { list: () => [{ accountId: 'account-a', redditUsername: 'operator' }] }, redditAccounts: { list: () => [{ accountId: 'account-a', redditUsername: 'operator' }] },
@@ -72,6 +73,7 @@ describe('outreach route', () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(parsed).toMatchObject({ expect(parsed).toMatchObject({
physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 },
activeCount: 6, activeCount: 6,
stages: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1 }, stages: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1 },
outcomes: { sent: 2, rejected: 1 }, outcomes: { sent: 2, rejected: 1 },