feat: make the conversation kill controls settable from the dashboard
The `conversations` section was only reachable by editing config.yaml, which production ignores: config is database-backed and the YAML is imported once, when the database has not yet been seeded. `AdvancedConfigSchema` is also a strict object, so a PUT carrying the section was rejected outright. - Add `conversations` to the advanced config schema and payload, so it appears in the dashboard's advanced JSON editor and survives a save. - Apply the edited section field by field rather than replacing the objects, and read the flags through to the live config from the account runtime, so a save applies to a running account without a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4404ae75ff
commit
0a4e0064f2
4 files changed
+148
-7
No files matched your search
@@ -257,16 +257,15 @@ async function buildRuntime(
|
|||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
mediaExchange,
|
mediaExchange,
|
||||||
|
// Read through to the live config object rather than snapshotting, so a
|
||||||
|
// dashboard configuration save applies to a running account without a
|
||||||
|
// restart. The service reads these on every inbound reply.
|
||||||
{
|
{
|
||||||
offPlatformKill: accountConfig.conversations.offPlatformKill,
|
get offPlatformKill() { return accountConfig.conversations.offPlatformKill; },
|
||||||
killOnSellerKeyword: accountConfig.conversations.killOnSellerKeyword,
|
get killOnSellerKeyword() { return accountConfig.conversations.killOnSellerKeyword; },
|
||||||
triage: conversationReadStateRepo,
|
triage: conversationReadStateRepo,
|
||||||
screener: aiClient,
|
screener: aiClient,
|
||||||
screening: {
|
get screening() { return accountConfig.conversations.aiReplyScreening; },
|
||||||
enabled: accountConfig.conversations.aiReplyScreening.enabled,
|
|
||||||
killConfidence: accountConfig.conversations.aiReplyScreening.killConfidence,
|
|
||||||
flagConfidence: accountConfig.conversations.aiReplyScreening.flagConfidence,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const AdvancedConfigSchema = z.strictObject({
|
|||||||
}),
|
}),
|
||||||
openrouter: ConfigSchema.shape.openrouter.omit({ apiKey: true }),
|
openrouter: ConfigSchema.shape.openrouter.omit({ apiKey: true }),
|
||||||
browser: z.strictObject({ sessionDir: z.string() }),
|
browser: z.strictObject({ sessionDir: z.string() }),
|
||||||
|
conversations: ConfigSchema.shape.conversations,
|
||||||
media: ConfigSchema.shape.media,
|
media: ConfigSchema.shape.media,
|
||||||
strategies: ConfigSchema.shape.strategies,
|
strategies: ConfigSchema.shape.strategies,
|
||||||
chatStrategies: ConfigSchema.shape.chatStrategies,
|
chatStrategies: ConfigSchema.shape.chatStrategies,
|
||||||
@@ -33,6 +34,7 @@ function advancedConfig(ctx: WebContext): z.infer<typeof AdvancedConfigSchema> {
|
|||||||
reddit,
|
reddit,
|
||||||
openrouter,
|
openrouter,
|
||||||
browser,
|
browser,
|
||||||
|
conversations: ctx.config.conversations,
|
||||||
media: ctx.config.media,
|
media: ctx.config.media,
|
||||||
strategies: ctx.config.strategies,
|
strategies: ctx.config.strategies,
|
||||||
chatStrategies: ctx.config.chatStrategies,
|
chatStrategies: ctx.config.chatStrategies,
|
||||||
@@ -140,6 +142,13 @@ export function createConfigRouter(ctx: WebContext): Router {
|
|||||||
Object.assign(ctx.config.reddit, parsedAdvanced.data.reddit);
|
Object.assign(ctx.config.reddit, parsedAdvanced.data.reddit);
|
||||||
Object.assign(ctx.config.openrouter, parsedAdvanced.data.openrouter);
|
Object.assign(ctx.config.openrouter, parsedAdvanced.data.openrouter);
|
||||||
Object.assign(ctx.config.browser, parsedAdvanced.data.browser);
|
Object.assign(ctx.config.browser, parsedAdvanced.data.browser);
|
||||||
|
// Assigned field by field, never replaced: running account runtimes
|
||||||
|
// hold a reference to these objects and read the kill controls on every
|
||||||
|
// reply, so a dashboard save applies without a restart.
|
||||||
|
const nextConversations = parsedAdvanced.data.conversations;
|
||||||
|
ctx.config.conversations.offPlatformKill = nextConversations.offPlatformKill;
|
||||||
|
ctx.config.conversations.killOnSellerKeyword = nextConversations.killOnSellerKeyword;
|
||||||
|
Object.assign(ctx.config.conversations.aiReplyScreening, nextConversations.aiReplyScreening);
|
||||||
ctx.config.media = parsedAdvanced.data.media;
|
ctx.config.media = parsedAdvanced.data.media;
|
||||||
ctx.config.strategies = parsedAdvanced.data.strategies;
|
ctx.config.strategies = parsedAdvanced.data.strategies;
|
||||||
ctx.config.chatStrategies = parsedAdvanced.data.chatStrategies;
|
ctx.config.chatStrategies = parsedAdvanced.data.chatStrategies;
|
||||||
|
|||||||
@@ -327,6 +327,28 @@ describe('conversation automation', () => {
|
|||||||
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
|
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('re-reads the screening options on every reply so a config change applies without a restart', async () => {
|
||||||
|
const screener = {
|
||||||
|
screenConversationReply: vi.fn().mockResolvedValue({
|
||||||
|
classification: 'seller', confidence: 0.95, evidence: 'selling',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const screening = { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 };
|
||||||
|
const { service, getSaved } = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }, undefined, undefined, {
|
||||||
|
screener,
|
||||||
|
screening,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await service.processConversation(incoming(1)))?.kind).toBe('sent');
|
||||||
|
expect(screener.screenConversationReply).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
screening.enabled = true;
|
||||||
|
|
||||||
|
expect((await service.processConversation(incoming(2)))?.kind).toBe('killed');
|
||||||
|
expect(screener.screenConversationReply).toHaveBeenCalledOnce();
|
||||||
|
expect(getSaved()?.automation.status).toBe('killed');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not call the screener at all when screening is disabled', async () => {
|
it('does not call the screener at all when screening is disabled', async () => {
|
||||||
const screener = { screenConversationReply: vi.fn() };
|
const screener = { screenConversationReply: vi.fn() };
|
||||||
const { service } = harness([], [], 0, undefined, undefined, undefined, {
|
const { service } = harness([], [], 0, undefined, undefined, undefined, {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { WebContext } from '../../src/web/context.js';
|
||||||
|
import { createConfigRouter } from '../../src/web/routes/config.js';
|
||||||
|
import { ConfigSchema, type Config } from '../../src/config/index.js';
|
||||||
|
import { getTestConfig } from '../testUtils.js';
|
||||||
|
|
||||||
|
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
||||||
|
|
||||||
|
async function request(app: express.Express, path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const server = app.listen(0);
|
||||||
|
servers.push(server);
|
||||||
|
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
return fetch(`http://127.0.0.1:${port}${path}`, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
||||||
|
});
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
// Deep-cloned because zod reuses one default object across parses, so two
|
||||||
|
// harnesses would otherwise share the same `conversations` instance.
|
||||||
|
const config = structuredClone(ConfigSchema.parse(getTestConfig())) as Config;
|
||||||
|
const saved: Config[] = [];
|
||||||
|
const ctx = {
|
||||||
|
config,
|
||||||
|
runtimeConfig: config,
|
||||||
|
services: { configStore: { save: (next: Config) => saved.push(next) } },
|
||||||
|
getOnConfigChange: () => undefined,
|
||||||
|
activity: { push: vi.fn() },
|
||||||
|
broadcaster: { push: vi.fn() },
|
||||||
|
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||||
|
} as unknown as WebContext;
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api', createConfigRouter(ctx));
|
||||||
|
return { app, config, saved };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('config route conversation kill controls', () => {
|
||||||
|
it('exposes the conversation section in the advanced payload', async () => {
|
||||||
|
const { app } = harness();
|
||||||
|
|
||||||
|
const body = await (await request(app, '/api/config')).json();
|
||||||
|
|
||||||
|
expect(body.advanced.conversations).toEqual({
|
||||||
|
offPlatformKill: true,
|
||||||
|
killOnSellerKeyword: true,
|
||||||
|
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists an edited conversation section in place, so running runtimes see it', async () => {
|
||||||
|
const { app, config, saved } = harness();
|
||||||
|
const conversationsRef = config.conversations;
|
||||||
|
const screeningRef = config.conversations.aiReplyScreening;
|
||||||
|
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({
|
||||||
|
advanced: {
|
||||||
|
...current.advanced,
|
||||||
|
conversations: {
|
||||||
|
offPlatformKill: true,
|
||||||
|
killOnSellerKeyword: false,
|
||||||
|
aiReplyScreening: { enabled: true, killConfidence: 0.9, flagConfidence: 0.7 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// Same objects, mutated: an account runtime holding this reference reads
|
||||||
|
// the new values on its next reply without being rebuilt.
|
||||||
|
expect(config.conversations).toBe(conversationsRef);
|
||||||
|
expect(config.conversations.aiReplyScreening).toBe(screeningRef);
|
||||||
|
expect(config.conversations.killOnSellerKeyword).toBe(false);
|
||||||
|
expect(config.conversations.aiReplyScreening).toEqual({
|
||||||
|
enabled: true, killConfidence: 0.9, flagConfidence: 0.7,
|
||||||
|
});
|
||||||
|
expect(saved.at(-1)?.conversations.aiReplyScreening.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an out-of-range screening threshold', async () => {
|
||||||
|
const { app, config } = 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({
|
||||||
|
advanced: {
|
||||||
|
...current.advanced,
|
||||||
|
conversations: {
|
||||||
|
offPlatformKill: true,
|
||||||
|
killOnSellerKeyword: true,
|
||||||
|
aiReplyScreening: { enabled: true, killConfidence: 4, flagConfidence: 0.7 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(config.conversations.aiReplyScreening.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user