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

+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,