Add hybrid R4R media conversation mode
This commit is contained in:
1 parent
927636ec8b
commit
b73e305cf3
15 files changed
+356
-34
No files matched your search
@@ -77,11 +77,11 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
|
|||||||
reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`);
|
reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`);
|
||||||
}
|
}
|
||||||
const enablesAutonomousR4R = validation.data.payload.subreddits?.some((subreddit, index) => (
|
const enablesAutonomousR4R = validation.data.payload.subreddits?.some((subreddit, index) => (
|
||||||
subreddit.conversation.mode === 'r4r'
|
(subreddit.conversation.mode === 'r4r' || subreddit.conversation.mode === 'r4r-media')
|
||||||
&& persistedDraft.subreddits[index]?.conversationMode !== 'r4r'
|
&& persistedDraft.subreddits[index]?.conversationMode !== subreddit.conversation.mode
|
||||||
));
|
));
|
||||||
if (enablesAutonomousR4R) {
|
if (enablesAutonomousR4R) {
|
||||||
reasons.push('Autonomous R4R will send conversational replies without approval for the selected subreddit. Existing conversations keep their snapshotted policy.');
|
reasons.push('Autonomous R4R will send conversational replies without approval for the selected subreddit. The hybrid mode can make one optional image request after rapport. Existing conversations keep their snapshotted policy.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reasons.length > 0) {
|
if (reasons.length > 0) {
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ export interface SubredditDraft {
|
|||||||
weight: string;
|
weight: string;
|
||||||
tone: z.infer<typeof ToneKeySchema>;
|
tone: z.infer<typeof ToneKeySchema>;
|
||||||
scripted: string;
|
scripted: string;
|
||||||
conversationMode: 'legacy' | 'r4r' | 'manual';
|
conversationMode: 'legacy' | 'r4r' | 'r4r-media' | 'manual';
|
||||||
maxAutomatedTurns: string;
|
maxAutomatedTurns: string;
|
||||||
|
rapportTurnsBeforeMediaAsk: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsDraft {
|
export interface SettingsDraft {
|
||||||
@@ -77,6 +78,7 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
|
|||||||
scripted: subreddit.scripted ?? '',
|
scripted: subreddit.scripted ?? '',
|
||||||
conversationMode: subreddit.conversation.mode,
|
conversationMode: subreddit.conversation.mode,
|
||||||
maxAutomatedTurns: String(subreddit.conversation.maxAutomatedTurns),
|
maxAutomatedTurns: String(subreddit.conversation.maxAutomatedTurns),
|
||||||
|
rapportTurnsBeforeMediaAsk: String(subreddit.conversation.rapportTurnsBeforeMediaAsk),
|
||||||
})),
|
})),
|
||||||
advancedConfigJson: config.advanced ? JSON.stringify(config.advanced, null, 2) : '',
|
advancedConfigJson: config.advanced ? JSON.stringify(config.advanced, null, 2) : '',
|
||||||
};
|
};
|
||||||
@@ -93,9 +95,13 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
|||||||
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
|
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
|
||||||
requiredErrors[`subreddits.${index}.weight`] = 'Subreddit weight must be greater than 0.';
|
requiredErrors[`subreddits.${index}.weight`] = 'Subreddit weight must be greater than 0.';
|
||||||
}
|
}
|
||||||
if (subreddit.conversationMode === 'r4r' && subreddit.maxAutomatedTurns.trim() === '') {
|
if ((subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media')
|
||||||
|
&& subreddit.maxAutomatedTurns.trim() === '') {
|
||||||
requiredErrors[`subreddits.${index}.maxAutomatedTurns`] = 'Maximum automated turns is required.';
|
requiredErrors[`subreddits.${index}.maxAutomatedTurns`] = 'Maximum automated turns is required.';
|
||||||
}
|
}
|
||||||
|
if (subreddit.conversationMode === 'r4r-media' && subreddit.rapportTurnsBeforeMediaAsk.trim() === '') {
|
||||||
|
requiredErrors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] = 'Rapport turns is required.';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
|
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
|
||||||
|
|
||||||
@@ -128,9 +134,12 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
|||||||
scripted: subreddit.scripted.trim() === '' ? undefined : subreddit.scripted,
|
scripted: subreddit.scripted.trim() === '' ? undefined : subreddit.scripted,
|
||||||
conversation: {
|
conversation: {
|
||||||
mode: subreddit.conversationMode,
|
mode: subreddit.conversationMode,
|
||||||
maxAutomatedTurns: subreddit.conversationMode === 'r4r'
|
maxAutomatedTurns: subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media'
|
||||||
? Number(subreddit.maxAutomatedTurns)
|
? Number(subreddit.maxAutomatedTurns)
|
||||||
: 3,
|
: 3,
|
||||||
|
rapportTurnsBeforeMediaAsk: subreddit.conversationMode === 'r4r-media'
|
||||||
|
? Number(subreddit.rapportTurnsBeforeMediaAsk)
|
||||||
|
: 3,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -138,7 +147,9 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
for (const issue of parsed.error.issues) {
|
for (const issue of parsed.error.issues) {
|
||||||
const path = issue.path.join('.');
|
const pathParts = [...issue.path];
|
||||||
|
if (pathParts[0] === 'subreddits' && pathParts[2] === 'conversation') pathParts.splice(2, 1);
|
||||||
|
const path = pathParts.join('.');
|
||||||
if (!(path in errors)) errors[path] = issue.message;
|
if (!(path in errors)) errors[path] = issue.message;
|
||||||
}
|
}
|
||||||
return { errors };
|
return { errors };
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
|
|||||||
...draft,
|
...draft,
|
||||||
subreddits: [
|
subreddits: [
|
||||||
...draft.subreddits,
|
...draft.subreddits,
|
||||||
{ name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '', conversationMode: 'legacy', maxAutomatedTurns: '3' },
|
{ name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '', conversationMode: 'legacy', maxAutomatedTurns: '3', rapportTurnsBeforeMediaAsk: '3' },
|
||||||
],
|
],
|
||||||
})}
|
})}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -202,15 +202,18 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
|
|||||||
>
|
>
|
||||||
<option value="legacy">Legacy media asks</option>
|
<option value="legacy">Legacy media asks</option>
|
||||||
<option value="r4r">Autonomous R4R conversation</option>
|
<option value="r4r">Autonomous R4R conversation</option>
|
||||||
|
<option value="r4r-media">R4R then one media ask</option>
|
||||||
<option value="manual">Manual only</option>
|
<option value="manual">Manual only</option>
|
||||||
</select>
|
</select>
|
||||||
<small>{subreddit.conversationMode === 'r4r'
|
<small>{subreddit.conversationMode === 'r4r'
|
||||||
? 'Continues general conversation autonomously. It will not request sexual media.'
|
? 'Continues general conversation autonomously. It will not request sexual media.'
|
||||||
|
: subreddit.conversationMode === 'r4r-media'
|
||||||
|
? 'Builds rapport autonomously, then makes one optional, non-explicit image request.'
|
||||||
: subreddit.conversationMode === 'manual'
|
: subreddit.conversationMode === 'manual'
|
||||||
? 'Incoming replies are marked for human handling and are never answered automatically.'
|
? 'Incoming replies are marked for human handling and are never answered automatically.'
|
||||||
: 'Preserves the existing three-turn media-request flow.'}</small>
|
: 'Preserves the existing three-turn media-request flow.'}</small>
|
||||||
</label>
|
</label>
|
||||||
{subreddit.conversationMode === 'r4r' ? (
|
{subreddit.conversationMode === 'r4r' || subreddit.conversationMode === 'r4r-media' ? (
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
<span>Maximum automated turns</span>
|
<span>Maximum automated turns</span>
|
||||||
<input
|
<input
|
||||||
@@ -227,6 +230,23 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
|
|||||||
{errors[`subreddits.${index}.maxAutomatedTurns`] ? <small className="field-error" id={`subreddit-${index}-max-turns-error`}>{errors[`subreddits.${index}.maxAutomatedTurns`]}</small> : null}
|
{errors[`subreddits.${index}.maxAutomatedTurns`] ? <small className="field-error" id={`subreddit-${index}-max-turns-error`}>{errors[`subreddits.${index}.maxAutomatedTurns`]}</small> : null}
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
{subreddit.conversationMode === 'r4r-media' ? (
|
||||||
|
<label className="settings-field">
|
||||||
|
<span>Rapport turns before media ask</span>
|
||||||
|
<input
|
||||||
|
aria-describedby={errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? `subreddit-${index}-rapport-turns-error` : undefined}
|
||||||
|
aria-invalid={errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? 'true' : undefined}
|
||||||
|
aria-label={`Subreddit ${index + 1} rapport turns before media ask`}
|
||||||
|
max="19"
|
||||||
|
min="1"
|
||||||
|
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, rapportTurnsBeforeMediaAsk: event.target.value }))}
|
||||||
|
step="1"
|
||||||
|
type="number"
|
||||||
|
value={subreddit.rapportTurnsBeforeMediaAsk}
|
||||||
|
/>
|
||||||
|
{errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`] ? <small className="field-error" id={`subreddit-${index}-rapport-turns-error`}>{errors[`subreddits.${index}.rapportTurnsBeforeMediaAsk`]}</small> : null}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<label className="settings-field scripted-field">
|
<label className="settings-field scripted-field">
|
||||||
<span>Scripted message (optional)</span>
|
<span>Scripted message (optional)</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -141,6 +141,15 @@ Complete reference for every configuration option.
|
|||||||
|
|
||||||
### Reddit Settings
|
### Reddit Settings
|
||||||
|
|
||||||
|
Each subreddit also has a conversation behaviour in the dashboard:
|
||||||
|
|
||||||
|
- `legacy` preserves the existing three-turn media-request flow.
|
||||||
|
- `r4r` continues general conversation autonomously and never requests media.
|
||||||
|
- `r4r-media` continues general conversation for `rapportTurnsBeforeMediaAsk`, makes one optional non-explicit image request, and never repeats it.
|
||||||
|
- `manual` records the reply for human handling without sending an automated response.
|
||||||
|
|
||||||
|
`maxAutomatedTurns` is configurable for `r4r` and `r4r-media` up to 20. In hybrid mode, `rapportTurnsBeforeMediaAsk` must be lower than `maxAutomatedTurns`. A conversation snapshots these values when its first verified reply arrives, so later settings changes affect new conversations only.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
reddit:
|
reddit:
|
||||||
# Subreddits to monitor for new posts
|
# Subreddits to monitor for new posts
|
||||||
|
|||||||
@@ -26,9 +26,18 @@ export const RuntimeConfigSchema = z.strictObject({
|
|||||||
reddit: RuntimeRedditConfigSchema,
|
reddit: RuntimeRedditConfigSchema,
|
||||||
});
|
});
|
||||||
export const ConversationBehaviourSchema = z.strictObject({
|
export const ConversationBehaviourSchema = z.strictObject({
|
||||||
mode: z.union([z.literal('legacy'), z.literal('r4r'), z.literal('manual')]).default('legacy'),
|
mode: z.union([z.literal('legacy'), z.literal('r4r'), z.literal('r4r-media'), z.literal('manual')]).default('legacy'),
|
||||||
maxAutomatedTurns: z.number().int().min(1).max(20).default(3),
|
maxAutomatedTurns: z.number().int().min(1).max(20).default(3),
|
||||||
}).default({ mode: 'legacy', maxAutomatedTurns: 3 });
|
rapportTurnsBeforeMediaAsk: z.number().int().min(1).max(19).default(3),
|
||||||
|
}).superRefine((behaviour, context) => {
|
||||||
|
if (behaviour.mode === 'r4r-media' && behaviour.rapportTurnsBeforeMediaAsk >= behaviour.maxAutomatedTurns) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['rapportTurnsBeforeMediaAsk'],
|
||||||
|
message: 'Rapport turns must be lower than the maximum automated turns.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).default({ mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 });
|
||||||
export const SubredditConfigSchema = z.strictObject({
|
export const SubredditConfigSchema = z.strictObject({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ describe('dashboard endpoint contracts', () => {
|
|||||||
...payload,
|
...payload,
|
||||||
subreddits: [{
|
subreddits: [{
|
||||||
...payload.subreddits[0],
|
...payload.subreddits[0],
|
||||||
conversation: { mode: 'legacy', maxAutomatedTurns: 3 },
|
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
expect(ConfigResponseSchema.safeParse({ ...payload, tones: [1] }).success).toBe(false);
|
expect(ConfigResponseSchema.safeParse({ ...payload, tones: [1] }).success).toBe(false);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { AiUsageRecordInput, AiUsageOutcome } from '../database/ai-usage-re
|
|||||||
import {
|
import {
|
||||||
formatConversationAsUntrustedData,
|
formatConversationAsUntrustedData,
|
||||||
isSafeConversationReply,
|
isSafeConversationReply,
|
||||||
|
isSafeHybridMediaAsk,
|
||||||
type ConversationIntent,
|
type ConversationIntent,
|
||||||
type ConversationState,
|
type ConversationState,
|
||||||
} from '../services/conversation-state.js';
|
} from '../services/conversation-state.js';
|
||||||
@@ -377,6 +378,35 @@ Just write the message, nothing else.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateR4RMediaAsk(
|
||||||
|
conversationHistory: { sender: string; text: string; isSelf: boolean }[],
|
||||||
|
username: string,
|
||||||
|
state: ConversationState,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Generating one-time R4R media ask for conversation with ${username}...`);
|
||||||
|
const intent: ConversationIntent = {
|
||||||
|
kind: 'media-ask',
|
||||||
|
reason: 'one optional image invitation after reciprocal rapport',
|
||||||
|
};
|
||||||
|
const prompt = this.buildConversationPrompt(conversationHistory, username, state, intent);
|
||||||
|
const message = await this.generateTextWithFallback('generateR4RMediaAsk', {
|
||||||
|
system: prompt.system,
|
||||||
|
user: `${prompt.user}\n\nWrite the single image invitation for this conversation. Acknowledge or answer their latest message first, then naturally ask whether they would be comfortable sharing one ordinary selfie, photo, or short video. Make it explicitly optional and easy to decline. Do not ask for nudity, explicit or sexual media, proof, contact details, or a meeting. Do not pressure, dare, challenge, bargain, guilt, or imply that you will ask again.`,
|
||||||
|
}, {
|
||||||
|
max_tokens: 300,
|
||||||
|
temperature: 0.85,
|
||||||
|
});
|
||||||
|
if (!isSafeHybridMediaAsk(message)) {
|
||||||
|
throw new Error('Generated R4R media ask failed hybrid output safeguards');
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('R4R media-ask generation failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if message content contains upsell/spam keywords (OnlyFans, selling, etc.)
|
* Check if message content contains upsell/spam keywords (OnlyFans, selling, etc.)
|
||||||
*/
|
*/
|
||||||
|
|||||||
+11
-2
@@ -10,9 +10,18 @@ dotenv.config();
|
|||||||
export const MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS = 20;
|
export const MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS = 20;
|
||||||
|
|
||||||
export const ConversationBehaviourSchema = z.object({
|
export const ConversationBehaviourSchema = z.object({
|
||||||
mode: z.enum(['legacy', 'r4r', 'manual']).default('legacy'),
|
mode: z.enum(['legacy', 'r4r', 'r4r-media', 'manual']).default('legacy'),
|
||||||
maxAutomatedTurns: z.number().int().min(1).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS).default(3),
|
maxAutomatedTurns: z.number().int().min(1).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS).default(3),
|
||||||
}).default({ mode: 'legacy', maxAutomatedTurns: 3 });
|
rapportTurnsBeforeMediaAsk: z.number().int().min(1).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS - 1).default(3),
|
||||||
|
}).superRefine((behaviour, context) => {
|
||||||
|
if (behaviour.mode === 'r4r-media' && behaviour.rapportTurnsBeforeMediaAsk >= behaviour.maxAutomatedTurns) {
|
||||||
|
context.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['rapportTurnsBeforeMediaAsk'],
|
||||||
|
message: 'Rapport turns must be lower than the maximum automated turns.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).default({ mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 });
|
||||||
|
|
||||||
export type ConversationBehaviour = z.infer<typeof ConversationBehaviourSchema>;
|
export type ConversationBehaviour = z.infer<typeof ConversationBehaviourSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
chooseConversationIntent,
|
chooseConversationIntent,
|
||||||
conversationMessageFingerprint,
|
conversationMessageFingerprint,
|
||||||
createConversationAutomationState,
|
createConversationAutomationState,
|
||||||
|
isSafeHybridMediaAsk,
|
||||||
isSafeR4RReply,
|
isSafeR4RReply,
|
||||||
LEGACY_AUTOMATED_CONVERSATION_TURNS,
|
LEGACY_AUTOMATED_CONVERSATION_TURNS,
|
||||||
mergeConversationState,
|
mergeConversationState,
|
||||||
@@ -43,7 +44,7 @@ export interface ConversationAutomationClaims {
|
|||||||
/**
|
/**
|
||||||
* Sends at most one reply for each newly received user message. Durable state
|
* Sends at most one reply for each newly received user message. Durable state
|
||||||
* makes retries and restarts idempotent. The opener subreddit selects a
|
* makes retries and restarts idempotent. The opener subreddit selects a
|
||||||
* snapshotted legacy, autonomous R4R, or manual policy for the conversation.
|
* snapshotted legacy, autonomous R4R, hybrid R4R-to-media, or manual policy.
|
||||||
*/
|
*/
|
||||||
export class ConversationAutomationService {
|
export class ConversationAutomationService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -96,10 +97,14 @@ export class ConversationAutomationService {
|
|||||||
);
|
);
|
||||||
if (!savedState) {
|
if (!savedState) {
|
||||||
const behaviour = this.resolveConversationBehaviour(opener.subreddit);
|
const behaviour = this.resolveConversationBehaviour(opener.subreddit);
|
||||||
const maxTurns = behaviour.mode === 'r4r'
|
const maxTurns = behaviour.mode === 'r4r' || behaviour.mode === 'r4r-media'
|
||||||
? behaviour.maxAutomatedTurns
|
? behaviour.maxAutomatedTurns
|
||||||
: LEGACY_AUTOMATED_CONVERSATION_TURNS;
|
: LEGACY_AUTOMATED_CONVERSATION_TURNS;
|
||||||
state.automation = createConversationAutomationState(behaviour.mode, maxTurns);
|
state.automation = createConversationAutomationState(
|
||||||
|
behaviour.mode,
|
||||||
|
maxTurns,
|
||||||
|
behaviour.rapportTurnsBeforeMediaAsk,
|
||||||
|
);
|
||||||
// Snapshot before generation so retries, restarts, dry runs, and later
|
// Snapshot before generation so retries, restarts, dry runs, and later
|
||||||
// configuration edits cannot change the policy mid-conversation.
|
// configuration edits cannot change the policy mid-conversation.
|
||||||
this.states.save(conversationId, state);
|
this.states.save(conversationId, state);
|
||||||
@@ -136,7 +141,7 @@ export class ConversationAutomationService {
|
|||||||
if (state.boundaries.length > 0) {
|
if (state.boundaries.length > 0) {
|
||||||
return this.finish(conversation, conversationId, state, incomingKey, 'boundary', state.boundaries.join('; '));
|
return this.finish(conversation, conversationId, state, incomingKey, 'boundary', state.boundaries.join('; '));
|
||||||
}
|
}
|
||||||
if (state.automation.policyMode === 'r4r') {
|
if (state.automation.policyMode === 'r4r' || state.automation.policyMode === 'r4r-media') {
|
||||||
const safetyReason = r4rSafetyHandoffReason(latest.text);
|
const safetyReason = r4rSafetyHandoffReason(latest.text);
|
||||||
if (safetyReason) {
|
if (safetyReason) {
|
||||||
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
|
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
|
||||||
@@ -154,14 +159,26 @@ export class ConversationAutomationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isLegacy = state.automation.policyMode === 'legacy';
|
const isLegacy = state.automation.policyMode === 'legacy';
|
||||||
const intent = chooseConversationIntent(mapped, state, isLegacy ? 'media-ask' : undefined);
|
const isHybrid = state.automation.policyMode === 'r4r-media';
|
||||||
|
const isHybridMediaAsk = isHybrid
|
||||||
|
&& !state.askedForMedia
|
||||||
|
&& state.automation.turnsUsed >= state.automation.rapportTurnsBeforeMediaAsk;
|
||||||
|
if (isHybridMediaAsk) {
|
||||||
|
state.automation.hybridPhase = 'media-ask';
|
||||||
|
this.states.save(conversationId, state);
|
||||||
|
}
|
||||||
|
const requestsMedia = isLegacy || isHybridMediaAsk;
|
||||||
|
const intent = chooseConversationIntent(mapped, state, requestsMedia ? 'media-ask' : undefined);
|
||||||
if (isLegacy && intent.kind !== 'media-ask') {
|
if (isLegacy && intent.kind !== 'media-ask') {
|
||||||
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', intent.reason);
|
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', intent.reason);
|
||||||
}
|
}
|
||||||
const reply = isLegacy
|
const reply = isLegacy
|
||||||
? await this.ai.generateMediaAsk(mapped, conversation.username, state, intent)
|
? await this.ai.generateMediaAsk(mapped, conversation.username, state, intent)
|
||||||
|
: isHybridMediaAsk
|
||||||
|
? await this.ai.generateR4RMediaAsk(mapped, conversation.username, state)
|
||||||
: await this.ai.generateReply(mapped, conversation.username, state, intent);
|
: await this.ai.generateReply(mapped, conversation.username, state, intent);
|
||||||
if (!isLegacy && !isSafeR4RReply(reply)) {
|
const safeReply = isHybridMediaAsk ? isSafeHybridMediaAsk(reply) : isLegacy || isSafeR4RReply(reply);
|
||||||
|
if (!safeReply) {
|
||||||
return this.finish(
|
return this.finish(
|
||||||
conversation,
|
conversation,
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -181,11 +198,16 @@ export class ConversationAutomationService {
|
|||||||
// Persist the full intended state before the remote send. If the process
|
// Persist the full intended state before the remote send. If the process
|
||||||
// dies after Matrix accepts the message, both this state and the durable
|
// dies after Matrix accepts the message, both this state and the durable
|
||||||
// event claim make the retry fail closed instead of sending twice.
|
// event claim make the retry fail closed instead of sending twice.
|
||||||
if (isLegacy) state.askedForMedia = true;
|
if (isLegacy || isHybridMediaAsk) state.askedForMedia = true;
|
||||||
|
if (isHybridMediaAsk) state.automation.hybridPhase = 'post-media-ask';
|
||||||
state.automation.turnsUsed += 1;
|
state.automation.turnsUsed += 1;
|
||||||
state.automation.lastIncomingKey = incomingKey;
|
state.automation.lastIncomingKey = incomingKey;
|
||||||
state.automation.aiMessageFingerprints.push(conversationMessageFingerprint(reply));
|
state.automation.aiMessageFingerprints.push(conversationMessageFingerprint(reply));
|
||||||
const exhausted = state.automation.turnsUsed >= state.automation.maxAutomatedTurns;
|
// Keep the hybrid conversation active after its one media ask even when
|
||||||
|
// that send consumes the final turn. The next inbound event still needs
|
||||||
|
// to be classified as an image, a decline, or a normal turn-limit handoff.
|
||||||
|
const exhausted = state.automation.turnsUsed >= state.automation.maxAutomatedTurns
|
||||||
|
&& !isHybridMediaAsk;
|
||||||
if (exhausted) {
|
if (exhausted) {
|
||||||
state.automation.status = 'human-required';
|
state.automation.status = 'human-required';
|
||||||
state.automation.handoffReason = `${state.automation.maxAutomatedTurns} automated turns have been used.`;
|
state.automation.handoffReason = `${state.automation.maxAutomatedTurns} automated turns have been used.`;
|
||||||
@@ -214,7 +236,11 @@ export class ConversationAutomationService {
|
|||||||
const configured = subreddit
|
const configured = subreddit
|
||||||
? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())
|
? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())
|
||||||
: undefined;
|
: undefined;
|
||||||
return configured?.conversation ?? { mode: 'legacy', maxAutomatedTurns: LEGACY_AUTOMATED_CONVERSATION_TURNS };
|
return configured?.conversation ?? {
|
||||||
|
mode: 'legacy',
|
||||||
|
maxAutomatedTurns: LEGACY_AUTOMATED_CONVERSATION_TURNS,
|
||||||
|
rapportTurnsBeforeMediaAsk: LEGACY_AUTOMATED_CONVERSATION_TURNS,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private finish(
|
private finish(
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ export interface ConversationState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
|
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
|
||||||
export const CONVERSATION_POLICY_VERSION = 1;
|
export const DEFAULT_HYBRID_RAPPORT_TURNS = 3;
|
||||||
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'manual';
|
export const CONVERSATION_POLICY_VERSION = 2;
|
||||||
|
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
|
||||||
|
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
|
||||||
|
|
||||||
export type ConversationAutomationStatus =
|
export type ConversationAutomationStatus =
|
||||||
| 'active'
|
| 'active'
|
||||||
@@ -48,6 +50,8 @@ export interface ConversationAutomationState {
|
|||||||
policyMode: ConversationPolicyMode;
|
policyMode: ConversationPolicyMode;
|
||||||
policyVersion: number;
|
policyVersion: number;
|
||||||
maxAutomatedTurns: number;
|
maxAutomatedTurns: number;
|
||||||
|
rapportTurnsBeforeMediaAsk: number;
|
||||||
|
hybridPhase: HybridConversationPhase | null;
|
||||||
turnsUsed: number;
|
turnsUsed: number;
|
||||||
status: ConversationAutomationStatus;
|
status: ConversationAutomationStatus;
|
||||||
lastIncomingKey: string | null;
|
lastIncomingKey: string | null;
|
||||||
@@ -59,11 +63,14 @@ export interface ConversationAutomationState {
|
|||||||
export function createConversationAutomationState(
|
export function createConversationAutomationState(
|
||||||
policyMode: ConversationPolicyMode = 'legacy',
|
policyMode: ConversationPolicyMode = 'legacy',
|
||||||
maxAutomatedTurns = LEGACY_AUTOMATED_CONVERSATION_TURNS,
|
maxAutomatedTurns = LEGACY_AUTOMATED_CONVERSATION_TURNS,
|
||||||
|
rapportTurnsBeforeMediaAsk = DEFAULT_HYBRID_RAPPORT_TURNS,
|
||||||
): ConversationAutomationState {
|
): ConversationAutomationState {
|
||||||
return {
|
return {
|
||||||
policyMode,
|
policyMode,
|
||||||
policyVersion: CONVERSATION_POLICY_VERSION,
|
policyVersion: CONVERSATION_POLICY_VERSION,
|
||||||
maxAutomatedTurns,
|
maxAutomatedTurns,
|
||||||
|
rapportTurnsBeforeMediaAsk,
|
||||||
|
hybridPhase: policyMode === 'r4r-media' ? 'rapport' : null,
|
||||||
turnsUsed: 0,
|
turnsUsed: 0,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
lastIncomingKey: null,
|
lastIncomingKey: null,
|
||||||
@@ -205,6 +212,14 @@ export function isSafeR4RReply(message: string): boolean {
|
|||||||
&& !/\b(can|could|would|will) you\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes)\b/i.test(message);
|
&& !/\b(can|could|would|will) you\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes)\b/i.test(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The hybrid policy gets one optional, non-explicit image request after rapport. */
|
||||||
|
export function isSafeHybridMediaAsk(message: string): boolean {
|
||||||
|
if (!isSafeConversationReply(message)) return false;
|
||||||
|
if (!/\b(pic|pics|photo|photos|selfie|selfies|video|videos)\b/i.test(message)) return false;
|
||||||
|
if (/\b(nude|nudes|naked|explicit|nsfw|sexy pic|sexual)\b/i.test(message)) return false;
|
||||||
|
return !/\b(must|prove|dare|owe|have to|need to)\b/i.test(message);
|
||||||
|
}
|
||||||
|
|
||||||
export function serializeConversationState(state: ConversationState): string {
|
export function serializeConversationState(state: ConversationState): string {
|
||||||
return JSON.stringify(state);
|
return JSON.stringify(state);
|
||||||
}
|
}
|
||||||
@@ -238,7 +253,9 @@ export function parseConversationState(value: string | null | undefined): Conver
|
|||||||
function parseAutomationState(value: unknown): ConversationAutomationState {
|
function parseAutomationState(value: unknown): ConversationAutomationState {
|
||||||
if (!value || typeof value !== 'object') return createConversationAutomationState();
|
if (!value || typeof value !== 'object') return createConversationAutomationState();
|
||||||
const parsed = value as Partial<ConversationAutomationState>;
|
const parsed = value as Partial<ConversationAutomationState>;
|
||||||
const policyMode: ConversationPolicyMode = parsed.policyMode === 'r4r' || parsed.policyMode === 'manual'
|
const policyMode: ConversationPolicyMode = parsed.policyMode === 'r4r'
|
||||||
|
|| parsed.policyMode === 'r4r-media'
|
||||||
|
|| parsed.policyMode === 'manual'
|
||||||
? parsed.policyMode
|
? parsed.policyMode
|
||||||
: 'legacy';
|
: 'legacy';
|
||||||
const maxAutomatedTurns = typeof parsed.maxAutomatedTurns === 'number'
|
const maxAutomatedTurns = typeof parsed.maxAutomatedTurns === 'number'
|
||||||
@@ -248,12 +265,22 @@ function parseAutomationState(value: unknown): ConversationAutomationState {
|
|||||||
parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary' || parsed.status === 'user-overridden'
|
parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary' || parsed.status === 'user-overridden'
|
||||||
? parsed.status
|
? parsed.status
|
||||||
: 'active';
|
: 'active';
|
||||||
|
const rapportTurnsBeforeMediaAsk = typeof parsed.rapportTurnsBeforeMediaAsk === 'number'
|
||||||
|
? Math.max(1, Math.min(19, Math.trunc(parsed.rapportTurnsBeforeMediaAsk)))
|
||||||
|
: DEFAULT_HYBRID_RAPPORT_TURNS;
|
||||||
|
const hybridPhase: HybridConversationPhase | null = policyMode !== 'r4r-media'
|
||||||
|
? null
|
||||||
|
: parsed.hybridPhase === 'media-ask' || parsed.hybridPhase === 'post-media-ask'
|
||||||
|
? parsed.hybridPhase
|
||||||
|
: 'rapport';
|
||||||
return {
|
return {
|
||||||
policyMode,
|
policyMode,
|
||||||
policyVersion: typeof parsed.policyVersion === 'number' && parsed.policyVersion > 0
|
policyVersion: typeof parsed.policyVersion === 'number' && parsed.policyVersion > 0
|
||||||
? Math.trunc(parsed.policyVersion)
|
? Math.trunc(parsed.policyVersion)
|
||||||
: CONVERSATION_POLICY_VERSION,
|
: CONVERSATION_POLICY_VERSION,
|
||||||
maxAutomatedTurns,
|
maxAutomatedTurns,
|
||||||
|
rapportTurnsBeforeMediaAsk,
|
||||||
|
hybridPhase,
|
||||||
turnsUsed: typeof parsed.turnsUsed === 'number'
|
turnsUsed: typeof parsed.turnsUsed === 'number'
|
||||||
? Math.max(0, Math.min(maxAutomatedTurns, Math.trunc(parsed.turnsUsed)))
|
? Math.max(0, Math.min(maxAutomatedTurns, Math.trunc(parsed.turnsUsed)))
|
||||||
: 0,
|
: 0,
|
||||||
|
|||||||
+17
-1
@@ -62,6 +62,7 @@ describe('ConfigSchema', () => {
|
|||||||
expect(result.data.reddit.subreddits[0].conversation).toEqual({
|
expect(result.data.reddit.subreddits[0].conversation).toEqual({
|
||||||
mode: 'legacy',
|
mode: 'legacy',
|
||||||
maxAutomatedTurns: 3,
|
maxAutomatedTurns: 3,
|
||||||
|
rapportTurnsBeforeMediaAsk: 3,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -78,6 +79,21 @@ describe('ConfigSchema', () => {
|
|||||||
})).success).toBe(false);
|
})).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts the hybrid mode only when rapport completes before the turn limit', () => {
|
||||||
|
expect(ConfigSchema.safeParse(minimalConfig({
|
||||||
|
reddit: { subreddits: [{
|
||||||
|
name: 'r4r',
|
||||||
|
conversation: { mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 3 },
|
||||||
|
}] },
|
||||||
|
})).success).toBe(true);
|
||||||
|
expect(ConfigSchema.safeParse(minimalConfig({
|
||||||
|
reddit: { subreddits: [{
|
||||||
|
name: 'r4r',
|
||||||
|
conversation: { mode: 'r4r-media', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
|
||||||
|
}] },
|
||||||
|
})).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts valid tone values', () => {
|
it('accepts valid tone values', () => {
|
||||||
for (const tone of ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing']) {
|
for (const tone of ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing']) {
|
||||||
const result = ConfigSchema.safeParse({
|
const result = ConfigSchema.safeParse({
|
||||||
@@ -376,7 +392,7 @@ openrouter:
|
|||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
expect(config.reddit.subreddits).toEqual([{
|
expect(config.reddit.subreddits).toEqual([{
|
||||||
name: 'test-sub', enabled: true, type: 'image', tone: 'default',
|
name: 'test-sub', enabled: true, type: 'image', tone: 'default',
|
||||||
conversation: { mode: 'legacy', maxAutomatedTurns: 3 },
|
conversation: { mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 },
|
||||||
}]);
|
}]);
|
||||||
expect(config.reddit.pollIntervalMs).toBe(5000);
|
expect(config.reddit.pollIntervalMs).toBe(5000);
|
||||||
expect(config.openrouter.apiKey).toBe('yaml-key');
|
expect(config.openrouter.apiKey).toBe('yaml-key');
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ function harness(
|
|||||||
sellerKeywords: string[] = [],
|
sellerKeywords: string[] = [],
|
||||||
sellerDomains: string[] = [],
|
sellerDomains: string[] = [],
|
||||||
openerSentAt: number | null = 0,
|
openerSentAt: number | null = 0,
|
||||||
subredditConversation?: { mode: 'legacy' | 'r4r' | 'manual'; maxAutomatedTurns: number },
|
subredditConversation?: {
|
||||||
|
mode: 'legacy' | 'r4r' | 'r4r-media' | 'manual';
|
||||||
|
maxAutomatedTurns: number;
|
||||||
|
rapportTurnsBeforeMediaAsk?: number;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
let saved: ConversationState | undefined;
|
let saved: ConversationState | undefined;
|
||||||
const chat = {
|
const chat = {
|
||||||
@@ -16,6 +20,7 @@ function harness(
|
|||||||
};
|
};
|
||||||
const ai = {
|
const ai = {
|
||||||
generateMediaAsk: vi.fn().mockResolvedValue('Would you be comfortable sharing a selfie?'),
|
generateMediaAsk: vi.fn().mockResolvedValue('Would you be comfortable sharing a selfie?'),
|
||||||
|
generateR4RMediaAsk: vi.fn().mockResolvedValue('Would you be comfortable sharing a selfie?'),
|
||||||
generateReply: vi.fn().mockResolvedValue('That sounds fun. What got you into it?'),
|
generateReply: vi.fn().mockResolvedValue('That sounds fun. What got you into it?'),
|
||||||
};
|
};
|
||||||
const states = {
|
const states = {
|
||||||
@@ -39,7 +44,10 @@ function harness(
|
|||||||
chat as never, ai as never, states as never, openers as never, claims, rateLimiter as never, logger as never,
|
chat as never, ai as never, states as never, openers as never, claims, rateLimiter as never, logger as never,
|
||||||
false, sellerKeywords, sellerDomains,
|
false, sellerKeywords, sellerDomains,
|
||||||
() => subredditConversation ? [{
|
() => subredditConversation ? [{
|
||||||
name: 'r4r', enabled: true, type: 'chat', tone: 'default', conversation: subredditConversation,
|
name: 'r4r', enabled: true, type: 'chat', tone: 'default', conversation: {
|
||||||
|
...subredditConversation,
|
||||||
|
rapportTurnsBeforeMediaAsk: subredditConversation.rapportTurnsBeforeMediaAsk ?? 3,
|
||||||
|
},
|
||||||
}] : [],
|
}] : [],
|
||||||
);
|
);
|
||||||
return { service, chat, ai, states, openers, claims, getSaved: () => saved };
|
return { service, chat, ai, states, openers, claims, getSaved: () => saved };
|
||||||
@@ -167,13 +175,16 @@ describe('conversation automation', () => {
|
|||||||
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
|
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||||
expect(chat.sendChatMessage).toHaveBeenCalledTimes(2);
|
expect(chat.sendChatMessage).toHaveBeenCalledTimes(2);
|
||||||
expect(getSaved()?.automation).toMatchObject({
|
expect(getSaved()?.automation).toMatchObject({
|
||||||
policyMode: 'r4r', policyVersion: 1, maxAutomatedTurns: 2, turnsUsed: 2,
|
policyMode: 'r4r', policyVersion: 2, maxAutomatedTurns: 2, turnsUsed: 2,
|
||||||
});
|
});
|
||||||
expect(getSaved()?.askedForMedia).toBe(false);
|
expect(getSaved()?.askedForMedia).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('snapshots the r4r policy so later configuration changes do not switch an active conversation', async () => {
|
it('snapshots the r4r policy so later configuration changes do not switch an active conversation', async () => {
|
||||||
const behaviour: { mode: 'legacy' | 'r4r' | 'manual'; maxAutomatedTurns: number } = {
|
const behaviour: {
|
||||||
|
mode: 'legacy' | 'r4r' | 'r4r-media' | 'manual';
|
||||||
|
maxAutomatedTurns: number;
|
||||||
|
} = {
|
||||||
mode: 'r4r', maxAutomatedTurns: 2,
|
mode: 'r4r', maxAutomatedTurns: 2,
|
||||||
};
|
};
|
||||||
const { service, ai, getSaved } = harness([], [], 0, behaviour);
|
const { service, ai, getSaved } = harness([], [], 0, behaviour);
|
||||||
@@ -218,4 +229,72 @@ describe('conversation automation', () => {
|
|||||||
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
|
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
|
||||||
expect(fixture.getSaved()?.automation.handoffReason).toContain('failed autonomous safety checks');
|
expect(fixture.getSaved()?.automation.handoffReason).toContain('failed autonomous safety checks');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds rapport before making exactly one optional media ask in hybrid mode', async () => {
|
||||||
|
const fixture = harness([], [], 0, {
|
||||||
|
mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fixture.service.processConversation(incoming(1));
|
||||||
|
await fixture.service.processConversation(incoming(2));
|
||||||
|
const mediaAsk = await fixture.service.processConversation(incoming(3));
|
||||||
|
await fixture.service.processConversation(incoming(4));
|
||||||
|
|
||||||
|
expect(mediaAsk?.kind).toBe('sent');
|
||||||
|
expect(fixture.ai.generateReply).toHaveBeenCalledTimes(3);
|
||||||
|
expect(fixture.ai.generateR4RMediaAsk).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fixture.ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(4);
|
||||||
|
expect(fixture.getSaved()?.askedForMedia).toBe(true);
|
||||||
|
expect(fixture.getSaved()?.automation).toMatchObject({
|
||||||
|
policyMode: 'r4r-media',
|
||||||
|
policyVersion: 2,
|
||||||
|
rapportTurnsBeforeMediaAsk: 2,
|
||||||
|
hybridPhase: 'post-media-ask',
|
||||||
|
turnsUsed: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops the hybrid flow when the media ask is declined', async () => {
|
||||||
|
const fixture = harness([], [], 0, {
|
||||||
|
mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 1,
|
||||||
|
});
|
||||||
|
await fixture.service.processConversation(incoming(1));
|
||||||
|
await fixture.service.processConversation(incoming(2));
|
||||||
|
|
||||||
|
const result = await fixture.service.processConversation(incoming(3, "No thanks, I don't send photos."));
|
||||||
|
|
||||||
|
expect(result?.kind).toBe('boundary');
|
||||||
|
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.ai.generateR4RMediaAsk).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can record an image received after the media ask consumes the final turn', async () => {
|
||||||
|
const fixture = harness([], [], 0, {
|
||||||
|
mode: 'r4r-media', maxAutomatedTurns: 2, rapportTurnsBeforeMediaAsk: 1,
|
||||||
|
});
|
||||||
|
await fixture.service.processConversation(incoming(1));
|
||||||
|
const mediaAsk = await fixture.service.processConversation(incoming(2));
|
||||||
|
|
||||||
|
const result = await fixture.service.processConversation(incoming(3, '[Image]', [{
|
||||||
|
originalUrl: 'mxc://image', localPath: 'image.jpg', fileName: 'image.jpg',
|
||||||
|
}]));
|
||||||
|
|
||||||
|
expect(mediaAsk).toMatchObject({ kind: 'sent', remainingTurns: 0 });
|
||||||
|
expect(result?.kind).toBe('image-received');
|
||||||
|
expect(fixture.getSaved()?.automation.status).toBe('image-received');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unsafe hybrid media generation without sending it', async () => {
|
||||||
|
const fixture = harness([], [], 0, {
|
||||||
|
mode: 'r4r-media', maxAutomatedTurns: 4, rapportTurnsBeforeMediaAsk: 1,
|
||||||
|
});
|
||||||
|
fixture.ai.generateR4RMediaAsk.mockResolvedValue('Send me a nude.');
|
||||||
|
await fixture.service.processConversation(incoming(1));
|
||||||
|
|
||||||
|
const result = await fixture.service.processConversation(incoming(2));
|
||||||
|
|
||||||
|
expect(result?.kind).toBe('human-required');
|
||||||
|
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
formatConversationAsUntrustedData,
|
formatConversationAsUntrustedData,
|
||||||
mergeConversationState,
|
mergeConversationState,
|
||||||
isSafeConversationReply,
|
isSafeConversationReply,
|
||||||
|
isSafeHybridMediaAsk,
|
||||||
isSafeR4RReply,
|
isSafeR4RReply,
|
||||||
parseConversationState,
|
parseConversationState,
|
||||||
remainingAutomationTurns,
|
remainingAutomationTurns,
|
||||||
@@ -72,7 +73,10 @@ describe('conversation state', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
expect(state?.automation).toMatchObject({ turnsUsed: 0, status: 'active' });
|
expect(state?.automation).toMatchObject({ turnsUsed: 0, status: 'active' });
|
||||||
expect(state?.automation).toMatchObject({ policyMode: 'legacy', policyVersion: 1, maxAutomatedTurns: 3 });
|
expect(state?.automation).toMatchObject({
|
||||||
|
policyMode: 'legacy', policyVersion: 2, maxAutomatedTurns: 3,
|
||||||
|
rapportTurnsBeforeMediaAsk: 3, hybridPhase: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats conversation content as untrusted data rather than instructions', () => {
|
it('treats conversation content as untrusted data rather than instructions', () => {
|
||||||
@@ -106,4 +110,11 @@ describe('conversation state', () => {
|
|||||||
expect(isSafeR4RReply('Would you send me a nude?')).toBe(false);
|
expect(isSafeR4RReply('Would you send me a nude?')).toBe(false);
|
||||||
expect(isSafeR4RReply('I love live music too. What was your last gig?')).toBe(true);
|
expect(isSafeR4RReply('I love live music too. What was your last gig?')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows only optional non-explicit media asks in the hybrid phase', () => {
|
||||||
|
expect(isSafeHybridMediaAsk('Would you be comfortable sharing a selfie?')).toBe(true);
|
||||||
|
expect(isSafeHybridMediaAsk('Would you send me a nude?')).toBe(false);
|
||||||
|
expect(isSafeHybridMediaAsk('You have to prove it by sending a photo.')).toBe(false);
|
||||||
|
expect(isSafeHybridMediaAsk('What music are you into?')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -231,7 +231,7 @@ describe('settings and access', () => {
|
|||||||
|
|
||||||
it('configures autonomous r4r per subreddit and requires explicit confirmation', async () => {
|
it('configures autonomous r4r per subreddit and requires explicit confirmation', async () => {
|
||||||
installConfigApi();
|
installConfigApi();
|
||||||
let requestBody: { subreddits: Array<{ conversation: { mode: string; maxAutomatedTurns: number } }> } | undefined;
|
let requestBody: { subreddits: Array<{ conversation: { mode: string; maxAutomatedTurns: number; rapportTurnsBeforeMediaAsk: number } }> } | undefined;
|
||||||
server.use(http.put('/api/config', async ({ request }) => {
|
server.use(http.put('/api/config', async ({ request }) => {
|
||||||
requestBody = await request.json() as typeof requestBody;
|
requestBody = await request.json() as typeof requestBody;
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
@@ -259,7 +259,47 @@ describe('settings and access', () => {
|
|||||||
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
|
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
|
||||||
|
|
||||||
await waitFor(() => expect(requestBody?.subreddits[0].conversation).toEqual({
|
await waitFor(() => expect(requestBody?.subreddits[0].conversation).toEqual({
|
||||||
mode: 'r4r', maxAutomatedTurns: 7,
|
mode: 'r4r', maxAutomatedTurns: 7, rapportTurnsBeforeMediaAsk: 3,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('configures rapport and total turns for the autonomous hybrid mode', async () => {
|
||||||
|
installConfigApi();
|
||||||
|
let requestBody: {
|
||||||
|
subreddits: Array<{
|
||||||
|
conversation: { mode: string; maxAutomatedTurns: number; rapportTurnsBeforeMediaAsk: number };
|
||||||
|
}>;
|
||||||
|
} | undefined;
|
||||||
|
server.use(http.put('/api/config', async ({ request }) => {
|
||||||
|
requestBody = await request.json() as typeof requestBody;
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
config: { rateLimit: config.rateLimit, browser: config.browser, reddit: config.reddit },
|
||||||
|
subreddits: requestBody?.subreddits ?? config.subreddits,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderSettings('/settings?section=behaviour');
|
||||||
|
|
||||||
|
const behaviour = await screen.findByRole('combobox', { name: 'Subreddit 1 conversation behaviour' });
|
||||||
|
await user.selectOptions(behaviour, 'r4r-media');
|
||||||
|
const maxTurns = screen.getByRole('spinbutton', { name: 'Subreddit 1 maximum automated turns' });
|
||||||
|
const rapportTurns = screen.getByRole('spinbutton', { name: 'Subreddit 1 rapport turns before media ask' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||||
|
expect(screen.getByText('Rapport turns must be lower than the maximum automated turns.')).toBeInTheDocument();
|
||||||
|
expect(requestBody).toBeUndefined();
|
||||||
|
await user.clear(maxTurns);
|
||||||
|
await user.type(maxTurns, '6');
|
||||||
|
await user.clear(rapportTurns);
|
||||||
|
await user.type(rapportTurns, '3');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Confirm risky settings' });
|
||||||
|
expect(dialog).toHaveTextContent('hybrid mode can make one optional image request after rapport');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Apply risky settings' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestBody?.subreddits[0].conversation).toEqual({
|
||||||
|
mode: 'r4r-media', maxAutomatedTurns: 6, rapportTurnsBeforeMediaAsk: 3,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type OpenAI from 'openai';
|
|||||||
import { OpenRouterClient, stripDashes } from '../src/ai/openrouter.js';
|
import { OpenRouterClient, stripDashes } from '../src/ai/openrouter.js';
|
||||||
import { createMockLogger } from '../tests/mockLogger.js';
|
import { createMockLogger } from '../tests/mockLogger.js';
|
||||||
import { getTestConfig } from '../tests/testUtils.js';
|
import { getTestConfig } from '../tests/testUtils.js';
|
||||||
|
import { buildConversationState, createConversationAutomationState } from '../src/services/conversation-state.js';
|
||||||
|
|
||||||
describe('OpenRouterClient', () => {
|
describe('OpenRouterClient', () => {
|
||||||
let client: OpenRouterClient;
|
let client: OpenRouterClient;
|
||||||
@@ -728,6 +729,40 @@ describe('OpenRouterClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('generateR4RMediaAsk', () => {
|
||||||
|
it('requests one optional non-explicit image after rapport', async () => {
|
||||||
|
mockCreate.mockResolvedValueOnce({
|
||||||
|
choices: [{ message: { content: 'You sound lovely, would you be comfortable sharing a selfie?' } }],
|
||||||
|
});
|
||||||
|
const state = buildConversationState([
|
||||||
|
{ sender: 'me', text: '32M UK, fancy a chat?', isSelf: true },
|
||||||
|
{ sender: 'them', text: 'I love live music.', isSelf: false },
|
||||||
|
]);
|
||||||
|
state.automation = createConversationAutomationState('r4r-media', 6, 3);
|
||||||
|
state.automation.turnsUsed = 3;
|
||||||
|
|
||||||
|
const result = await client.generateR4RMediaAsk(
|
||||||
|
[{ sender: 'them', text: 'I love live music.', isSelf: false }],
|
||||||
|
'them',
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain('comfortable sharing a selfie');
|
||||||
|
const prompt = mockCreate.mock.calls[0][0].messages[1].content as string;
|
||||||
|
expect(prompt).toContain('single image invitation');
|
||||||
|
expect(prompt).toContain('Do not ask for nudity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an explicit media request', async () => {
|
||||||
|
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: 'Send me a nude.' } }] });
|
||||||
|
const state = buildConversationState([]);
|
||||||
|
state.automation = createConversationAutomationState('r4r-media', 6, 3);
|
||||||
|
|
||||||
|
await expect(client.generateR4RMediaAsk([], 'them', state))
|
||||||
|
.rejects.toThrow('hybrid output safeguards');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('records model, token, cost, latency, and outcome for each request attempt', async () => {
|
it('records model, token, cost, latency, and outcome for each request attempt', async () => {
|
||||||
const usageRecorder = vi.fn();
|
const usageRecorder = vi.fn();
|
||||||
const config = getTestConfig();
|
const config = getTestConfig();
|
||||||
|
|||||||
Reference in new issue
Block a user