Add hybrid R4R media conversation mode

This commit is contained in:
jjbrunton committed 2026-08-09 11:45:45 +01:00
1 parent 927636ec8b
commit b73e305cf3
15 files changed
+357 -35

No files matched your search

+30
View File
@@ -5,6 +5,7 @@ import type { AiUsageRecordInput, AiUsageOutcome } from '../database/ai-usage-re
import {
formatConversationAsUntrustedData,
isSafeConversationReply,
isSafeHybridMediaAsk,
type ConversationIntent,
type ConversationState,
} 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.)
*/
+11 -2
View File
@@ -10,9 +10,18 @@ dotenv.config();
export const MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS = 20;
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),
}).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>;
+36 -10
View File
@@ -11,6 +11,7 @@ import {
chooseConversationIntent,
conversationMessageFingerprint,
createConversationAutomationState,
isSafeHybridMediaAsk,
isSafeR4RReply,
LEGACY_AUTOMATED_CONVERSATION_TURNS,
mergeConversationState,
@@ -43,7 +44,7 @@ export interface ConversationAutomationClaims {
/**
* Sends at most one reply for each newly received user message. Durable state
* 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 {
constructor(
@@ -96,10 +97,14 @@ export class ConversationAutomationService {
);
if (!savedState) {
const behaviour = this.resolveConversationBehaviour(opener.subreddit);
const maxTurns = behaviour.mode === 'r4r'
const maxTurns = behaviour.mode === 'r4r' || behaviour.mode === 'r4r-media'
? behaviour.maxAutomatedTurns
: 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
// configuration edits cannot change the policy mid-conversation.
this.states.save(conversationId, state);
@@ -136,7 +141,7 @@ export class ConversationAutomationService {
if (state.boundaries.length > 0) {
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);
if (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 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') {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', intent.reason);
}
const reply = isLegacy
? await this.ai.generateMediaAsk(mapped, conversation.username, state, intent)
: await this.ai.generateReply(mapped, conversation.username, state, intent);
if (!isLegacy && !isSafeR4RReply(reply)) {
: isHybridMediaAsk
? await this.ai.generateR4RMediaAsk(mapped, conversation.username, state)
: await this.ai.generateReply(mapped, conversation.username, state, intent);
const safeReply = isHybridMediaAsk ? isSafeHybridMediaAsk(reply) : isLegacy || isSafeR4RReply(reply);
if (!safeReply) {
return this.finish(
conversation,
conversationId,
@@ -181,11 +198,16 @@ export class ConversationAutomationService {
// Persist the full intended state before the remote send. If the process
// dies after Matrix accepts the message, both this state and the durable
// 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.lastIncomingKey = incomingKey;
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) {
state.automation.status = 'human-required';
state.automation.handoffReason = `${state.automation.maxAutomatedTurns} automated turns have been used.`;
@@ -214,7 +236,11 @@ export class ConversationAutomationService {
const configured = subreddit
? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())
: 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(
+30 -3
View File
@@ -34,8 +34,10 @@ export interface ConversationState {
}
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
export const CONVERSATION_POLICY_VERSION = 1;
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'manual';
export const DEFAULT_HYBRID_RAPPORT_TURNS = 3;
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 =
| 'active'
@@ -48,6 +50,8 @@ export interface ConversationAutomationState {
policyMode: ConversationPolicyMode;
policyVersion: number;
maxAutomatedTurns: number;
rapportTurnsBeforeMediaAsk: number;
hybridPhase: HybridConversationPhase | null;
turnsUsed: number;
status: ConversationAutomationStatus;
lastIncomingKey: string | null;
@@ -59,11 +63,14 @@ export interface ConversationAutomationState {
export function createConversationAutomationState(
policyMode: ConversationPolicyMode = 'legacy',
maxAutomatedTurns = LEGACY_AUTOMATED_CONVERSATION_TURNS,
rapportTurnsBeforeMediaAsk = DEFAULT_HYBRID_RAPPORT_TURNS,
): ConversationAutomationState {
return {
policyMode,
policyVersion: CONVERSATION_POLICY_VERSION,
maxAutomatedTurns,
rapportTurnsBeforeMediaAsk,
hybridPhase: policyMode === 'r4r-media' ? 'rapport' : null,
turnsUsed: 0,
status: 'active',
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);
}
/** 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 {
return JSON.stringify(state);
}
@@ -238,7 +253,9 @@ export function parseConversationState(value: string | null | undefined): Conver
function parseAutomationState(value: unknown): ConversationAutomationState {
if (!value || typeof value !== 'object') return createConversationAutomationState();
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
: 'legacy';
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
: '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 {
policyMode,
policyVersion: typeof parsed.policyVersion === 'number' && parsed.policyVersion > 0
? Math.trunc(parsed.policyVersion)
: CONVERSATION_POLICY_VERSION,
maxAutomatedTurns,
rapportTurnsBeforeMediaAsk,
hybridPhase,
turnsUsed: typeof parsed.turnsUsed === 'number'
? Math.max(0, Math.min(maxAutomatedTurns, Math.trunc(parsed.turnsUsed)))
: 0,