Add hybrid R4R media conversation mode
This commit is contained in:
1 parent
927636ec8b
commit
b73e305cf3
15 files changed
+357
-35
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user