diff --git a/docs/configuration.md b/docs/configuration.md index 03f6646..36baf9f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,6 +39,8 @@ Never document real values. `.env` is ignored and may contain production credent - `maxReadyCandidates`: hard ceiling for the active candidate target; valid range 1–500. Runtime lowers the actual target to what authenticated running accounts can send within `recentWindowMinutes` under current rolling-hour and message-spacing limits. - `physiqueTargeting`: optional image-candidate filter with `enabled`, inclusive `minScore`, and inclusive `maxScore` values from 1 to 10. It is disabled by default. When enabled, a model score outside the range or an image without enough visible body evidence to score is rejected before opener generation. Saving the setting also reconciles prepared image candidates in the ready and delivery-retry stages: candidates that no longer qualify are rejected, while retained candidates receive the newly applied range snapshot shown in the funnel. A candidate already leased for sending is not changed mid-send. The setting is hot-read by existing account runtimes; chat sources are unaffected. +Physique targeting is owned only by its dedicated Monitoring controls. It is excluded from the advanced JSON response. Older cached dashboards may still submit a duplicate advanced copy; the server accepts but ignores that copy so it cannot overwrite the dedicated value persisted in `app_config`. + Subreddit `type` is `image` or `chat`. Conversation policy contains `mode`, `maxAutomatedTurns`, and `rapportTurnsBeforeMediaAsk`; hybrid rapport turns must be lower than the maximum. ### `conversations` diff --git a/src/web/routes/config.ts b/src/web/routes/config.ts index 4a14c1b..a2e6c31 100644 --- a/src/web/routes/config.ts +++ b/src/web/routes/config.ts @@ -16,7 +16,8 @@ const AdvancedConfigSchema = z.strictObject({ prioritizeRecentPosts: true, recentWindowMinutes: true, maxReadyCandidates: true, - }), + physiqueTargeting: true, + }).extend({ physiqueTargeting: PhysiqueTargetingSchema.optional() }), openrouter: ConfigSchema.shape.openrouter.omit({ apiKey: true }), browser: z.strictObject({ sessionDir: z.string() }), conversations: ConfigSchema.shape.conversations, @@ -31,6 +32,7 @@ function advancedConfig(ctx: WebContext): z.infer { prioritizeRecentPosts: _prioritize, recentWindowMinutes: _window, maxReadyCandidates: _maxReady, + physiqueTargeting: _physiqueTargeting, ...reddit } = ctx.config.reddit; const { apiKey: _apiKey, ...openrouter } = ctx.config.openrouter; @@ -158,7 +160,15 @@ export function createConfigRouter(ctx: WebContext): Router { } if (parsedAdvanced?.success) { - Object.assign(ctx.config.reddit, parsedAdvanced.data.reddit); + // Physique targeting has dedicated controls and is deliberately not + // owned by the advanced JSON. Cached dashboard bundles may still send + // an older copy here, so accept it but never let it overwrite the + // explicitly parsed top-level setting. + const { + physiqueTargeting: _legacyPhysiqueTargeting, + ...advancedReddit + } = parsedAdvanced.data.reddit; + Object.assign(ctx.config.reddit, advancedReddit); Object.assign(ctx.config.openrouter, parsedAdvanced.data.openrouter); Object.assign(ctx.config.browser, parsedAdvanced.data.browser); // Assigned field by field, never replaced: running account runtimes diff --git a/tests/web/config-route.test.ts b/tests/web/config-route.test.ts index c9a5e34..cf17854 100644 --- a/tests/web/config-route.test.ts +++ b/tests/web/config-route.test.ts @@ -139,6 +139,33 @@ describe('config route physique targeting', () => { ); }); + it('does not let a stale advanced-config copy overwrite dedicated physique controls', async () => { + const { app, config, saved } = harness(); + const current = await (await request(app, '/api/config')).json(); + + const response = await request(app, '/api/config', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + reddit: { + physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 }, + }, + advanced: { + ...current.advanced, + reddit: { + ...current.advanced.reddit, + physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 }, + }, + }, + }), + }); + + expect(response.status).toBe(200); + expect(config.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 1, maxScore: 6 }); + expect(saved.at(-1)?.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 1, maxScore: 6 }); + expect((await response.json()).advanced.reddit).not.toHaveProperty('physiqueTargeting'); + }); + it('rejects an inverted physique range without mutating configuration', async () => { const { app, config, saved } = harness();