diff --git a/apps/dashboard/src/features/outreach/outreach-page.tsx b/apps/dashboard/src/features/outreach/outreach-page.tsx index 1f36c57..c6c0cd0 100644 --- a/apps/dashboard/src/features/outreach/outreach-page.tsx +++ b/apps/dashboard/src/features/outreach/outreach-page.tsx @@ -38,7 +38,29 @@ function formatWaitTime(timestamp: number, now: number): string { 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; if (!preparation) return null; const targeting = preparation.physiqueTargeting; @@ -61,7 +83,7 @@ function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate
Attractiveness{preparation.attractivenessScore === null ? 'Not scored' : `${preparation.attractivenessScore}/100`}Used in the opener prompt
-
Physique{preparation.physiqueScore === null ? 'Not scorable' : `${preparation.physiqueScore}/10`}{targeting?.enabled ? `Target ${targeting.minScore}–${targeting.maxScore}` : 'Range filter off'}
+
Physique{preparation.physiqueScore === null ? 'Not scorable' : `${preparation.physiqueScore}/10`}{targetingLabel(currentTargeting, targeting)}
Detected gender{preparation.detectedGender}Image analysis
@@ -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 isKnownStatus = KNOWN_STATUSES.has(candidate.status); return ( @@ -114,7 +144,7 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
{candidate.note ?

Latest note{candidate.note}

: null} {candidate.owner ? : null} - + ); @@ -200,7 +230,14 @@ export function OutreachPage() {
The active queue is clear.New potential posts will appear here as discovery finds them.
) : (
    - {data.candidates.map((candidate) => )} + {data.candidates.map((candidate) => ( + + ))}
)} diff --git a/docs/data-flow.md b/docs/data-flow.md index 06e43a9..a067ee9 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -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. 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. diff --git a/docs/frontend.md b/docs/frontend.md index 4176a02..8fd5bae 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -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. -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 diff --git a/docs/product-and-safety.md b/docs/product-and-safety.md index b00a14b..52c36e5 100644 --- a/docs/product-and-safety.md +++ b/docs/product-and-safety.md @@ -30,7 +30,7 @@ Configured subreddits have a source type: - `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. -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. diff --git a/packages/api-contracts/src/outreach.ts b/packages/api-contracts/src/outreach.ts index d29b4b3..ed2c8ec 100644 --- a/packages/api-contracts/src/outreach.ts +++ b/packages/api-contracts/src/outreach.ts @@ -61,6 +61,11 @@ export const OutreachCandidateSchema = z.strictObject({ export const OutreachFunnelResponseSchema = z.strictObject({ 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(), stages: OutreachStageCountsSchema, outcomes: z.strictObject({ diff --git a/src/ai/openrouter.ts b/src/ai/openrouter.ts index 6504bc2..916b2a8 100644 --- a/src/ai/openrouter.ts +++ b/src/ai/openrouter.ts @@ -372,7 +372,18 @@ Use every applicable tag; tags may overlap. Set safety to "nsfw" when nudity, ex 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 -On the second line, assess only the directly visible physique. Output exactly PHYSIQUE_SCORE: 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: 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: @@ -400,6 +411,7 @@ ${this.config.openrouter.imageAnalysisPrompt}`; }, ], max_tokens: 600, + temperature: 0, }); } catch (error) { this.recordError('analyzeImage', model, startedAt, error); diff --git a/src/database/candidate-queue-repository.ts b/src/database/candidate-queue-repository.ts index 8d6107a..7fdd21d 100644 --- a/src/database/candidate-queue-repository.ts +++ b/src/database/candidate-queue-repository.ts @@ -292,7 +292,12 @@ export class CandidateQueueRepository { ...row.prepared, 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; })(); diff --git a/src/main-loop.ts b/src/main-loop.ts index 3cc1ce5..be4a0a8 100644 --- a/src/main-loop.ts +++ b/src/main-loop.ts @@ -643,8 +643,9 @@ async function runConcurrentAccountRuntimes(app: AppContainer): Promise { continue; } const target = candidateFunnelTarget(); - if (target > 0) candidateQueueRepo.promoteDueRetries(target); candidateQueueRepo.recoverExpiredLeases(); + candidateQueueRepo.reconcilePhysiqueTargeting(config.reddit.physiqueTargeting); + if (target > 0) candidateQueueRepo.promoteDueRetries(target); app.recipientDeduplicator.recoverStaleReservations(Date.now() - 10 * 60_000); const recipients = await availableRecipients(); const recipient = recipients[0]; diff --git a/src/web/routes/outreach.ts b/src/web/routes/outreach.ts index c6abc0a..986d608 100644 --- a/src/web/routes/outreach.ts +++ b/src/web/routes/outreach.ts @@ -112,6 +112,7 @@ export function createOutreachRouter(ctx: WebContext): Router { }; const response = { generatedAt: Date.now(), + physiqueTargeting: { ...ctx.runtimeConfig.reddit.physiqueTargeting }, activeCount: ACTIVE_STATUSES.reduce((total, status) => total + stats.byStatus[status], 0), stages, outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected }, diff --git a/tests/dashboard/app.test.tsx b/tests/dashboard/app.test.tsx index 7f9027b..4d0dd26 100644 --- a/tests/dashboard/app.test.tsx +++ b/tests/dashboard/app.test.tsx @@ -122,6 +122,7 @@ describe('dashboard foundation', () => { })), http.get('/api/outreach', () => HttpResponse.json({ generatedAt: Date.parse('2026-08-09T08:00:00Z'), + physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 }, activeCount: 3, stages: { discovered: 1, screening: 0, ready: 1, sending: 0, retryable: 1 }, outcomes: { sent: 12, rejected: 7 }, @@ -137,7 +138,7 @@ describe('dashboard foundation', () => { message: 'You look incredible in that pose — what inspired this post?', imageAnalysis: 'VISIBLE_FACTS:\nBODY: VISIBLE - full-body pose', 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', strategyId: 'direct-observation', experimentId: 'strategies:direct-observation:abc123', 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('68/100'); 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'); const sourceImage = within(preparation).getByRole('img', { name: 'Source post: [25F] A potential post' }); expect(sourceImage).toHaveClass('is-blurred'); diff --git a/tests/openrouter-client.test.ts b/tests/openrouter-client.test.ts index ad54759..c4800b5 100644 --- a/tests/openrouter-client.test.ts +++ b/tests/openrouter-client.test.ts @@ -335,6 +335,21 @@ describe('OpenRouterClient', () => { 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 () => { mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: 'GENDER:FEMALE\n\nA woman.' } }], diff --git a/tests/web/outreach-route.test.ts b/tests/web/outreach-route.test.ts index 8a5744c..77d9dc3 100644 --- a/tests/web/outreach-route.test.ts +++ b/tests/web/outreach-route.test.ts @@ -59,6 +59,7 @@ describe('outreach route', () => { }]), }; const context = { + runtimeConfig: { reddit: { physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 } } }, services: { candidateQueue, redditAccounts: { list: () => [{ accountId: 'account-a', redditUsername: 'operator' }] }, @@ -72,6 +73,7 @@ describe('outreach route', () => { expect(response.status).toBe(200); expect(parsed).toMatchObject({ + physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 }, activeCount: 6, stages: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1 }, outcomes: { sent: 2, rejected: 1 },