Harden conversation replies and prioritize newest candidates
This commit is contained in:
1 parent
f98c5c732d
commit
b8e4a1bb88
23 files changed
+598
-113
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import type { Logger } from '../../utils/logger.js';
|
||||
import type {
|
||||
ConversationOutcomesRepository,
|
||||
ConversationStateRepository,
|
||||
MessagesRepository,
|
||||
RedditUsersRepository,
|
||||
SentMessageRecord,
|
||||
@@ -21,6 +22,7 @@ import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown }
|
||||
export type { InvitationCooldown } from './types.js';
|
||||
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
|
||||
import type { AccountAuthGate } from '../account-auth-context.js';
|
||||
import { containsMediaRequest } from '../conversation-boundaries.js';
|
||||
|
||||
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
|
||||
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
|
||||
@@ -159,6 +161,7 @@ export class ChatService {
|
||||
private redditUsersRepo?: RedditUsersRepository,
|
||||
private accountAuth?: AccountAuthGate,
|
||||
private outcomesRepo?: ConversationOutcomesRepository,
|
||||
private conversationStateRepo?: ConversationStateRepository,
|
||||
) {
|
||||
this.cache = cache ?? new ChatCache(logger);
|
||||
if (this.redditUsersRepo) {
|
||||
@@ -1104,17 +1107,25 @@ export class ChatService {
|
||||
|
||||
if (hasReply && this.successfulMessagesRepo && this.outcomesRepo) {
|
||||
const firstReply = incomingMessages[0];
|
||||
const latestReply = incomingMessages.at(-1);
|
||||
const cachedSelfCount = conversationMessages.filter((message) => message.isSelf).length;
|
||||
const selfCount = cachedSelfCount + (firstSelf ? 0 : 1);
|
||||
const otherCount = incomingMessages.length;
|
||||
const wasMissingOutcome = this.outcomesRepo.get(performanceMessage.id) === undefined;
|
||||
const conversationState = this.conversationStateRepo?.get(chat.roomId);
|
||||
try {
|
||||
recordConversationReply(
|
||||
performanceMessage,
|
||||
firstReply?.text.slice(0, 200) ?? '',
|
||||
latestReply?.text.slice(0, 200) ?? '',
|
||||
{
|
||||
messageCount: selfCount + otherCount,
|
||||
reciprocal: selfCount >= 2 && otherCount >= 2,
|
||||
responses: incomingMessages.map((message) => message.text),
|
||||
learningEligible: conversationState !== undefined && conversationState.automation.status !== 'active',
|
||||
receivedImage: hasReceivedImage,
|
||||
mediaWasRequested: conversationState?.askedForMedia
|
||||
?? conversationMessages.some((message) => message.isSelf && containsMediaRequest(message.text)),
|
||||
authoritativeTranscript: true,
|
||||
},
|
||||
{
|
||||
messages: this.messagesRepo,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
remainingAutomationTurns,
|
||||
type ConversationState,
|
||||
} from './conversation-state.js';
|
||||
import { containsMediaRequest } from './conversation-boundaries.js';
|
||||
|
||||
export type ConversationAutomationResultKind =
|
||||
| 'sent'
|
||||
@@ -44,8 +45,14 @@ export interface ConversationAutomationClaims {
|
||||
markAttempt?(conversationId: string, incomingKey: string): boolean;
|
||||
releasePending?(conversationId: string, incomingKey: string): boolean;
|
||||
markSent(conversationId: string, incomingKey: string): boolean;
|
||||
markSuperseded?(conversationId: string, incomingKey: string): boolean;
|
||||
}
|
||||
|
||||
export type ConversationBeforeSendLoader = (
|
||||
conversationId: string,
|
||||
observed: ActiveChat,
|
||||
) => Promise<ActiveChat | undefined>;
|
||||
|
||||
export interface ReplyPriorityStatus {
|
||||
processing: boolean;
|
||||
actionableBacklog: number;
|
||||
@@ -86,6 +93,7 @@ export class ConversationAutomationService {
|
||||
private readonly configuredSubreddits: () => readonly SubredditConfig[] = () => [],
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS,
|
||||
private readonly beforeSendLoader?: ConversationBeforeSendLoader,
|
||||
) {}
|
||||
|
||||
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
|
||||
@@ -199,26 +207,21 @@ export class ConversationAutomationService {
|
||||
);
|
||||
}
|
||||
if (this.now() - existingClaim.claimedAt < this.pendingReplyRetryDelayMs) return null;
|
||||
const retryPlan = replyPlan(state);
|
||||
const exhausted = this.advanceStateForReply(
|
||||
state,
|
||||
conversationId,
|
||||
incomingKey,
|
||||
existingClaim.replyText,
|
||||
retryPlan.isLegacy,
|
||||
retryPlan.isHybridMediaAsk,
|
||||
);
|
||||
const requestsMedia = containsMediaRequest(existingClaim.replyText);
|
||||
const isHybridMediaAsk = state.automation.policyMode === 'r4r-media' && requestsMedia;
|
||||
return this.sendClaimedReply(
|
||||
conversation,
|
||||
state,
|
||||
incomingKey,
|
||||
existingClaim.replyText,
|
||||
latest.timestamp,
|
||||
exhausted,
|
||||
requestsMedia,
|
||||
isHybridMediaAsk,
|
||||
signal,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (existingClaim?.status === 'sent') return null;
|
||||
if (existingClaim?.status === 'sent' || existingClaim?.status === 'superseded') return null;
|
||||
if (state.automation.lastIncomingKey === incomingKey) return null;
|
||||
if (state.automation.status !== 'active') return null;
|
||||
|
||||
@@ -269,6 +272,16 @@ export class ConversationAutomationService {
|
||||
}
|
||||
|
||||
const { isLegacy, isHybridMediaAsk } = replyPlan(state);
|
||||
if (isLegacy && state.askedForMedia) {
|
||||
return this.finish(
|
||||
conversation,
|
||||
conversationId,
|
||||
state,
|
||||
incomingKey,
|
||||
'human-required',
|
||||
'A media request has already been made; further autonomous requests are disabled.',
|
||||
);
|
||||
}
|
||||
if (isHybridMediaAsk) {
|
||||
state.automation.hybridPhase = 'media-ask';
|
||||
this.states.save(conversationId, state);
|
||||
@@ -283,7 +296,9 @@ export class ConversationAutomationService {
|
||||
: 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);
|
||||
const safeReply = isLegacy || isHybridMediaAsk
|
||||
? isSafeHybridMediaAsk(reply)
|
||||
: isSafeR4RReply(reply);
|
||||
if (!safeReply) {
|
||||
return this.finish(
|
||||
conversation,
|
||||
@@ -291,7 +306,7 @@ export class ConversationAutomationService {
|
||||
state,
|
||||
incomingKey,
|
||||
'human-required',
|
||||
'The generated R4R reply failed autonomous safety checks.',
|
||||
'The generated reply failed autonomous safety checks.',
|
||||
);
|
||||
}
|
||||
if (this.dryRun) {
|
||||
@@ -301,24 +316,16 @@ export class ConversationAutomationService {
|
||||
|
||||
if (!this.claims.tryClaim(conversationId, incomingKey, reply, latest.timestamp)) return null;
|
||||
|
||||
// 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.
|
||||
const exhausted = this.advanceStateForReply(
|
||||
state,
|
||||
conversationId,
|
||||
incomingKey,
|
||||
reply,
|
||||
isLegacy,
|
||||
isHybridMediaAsk,
|
||||
);
|
||||
// The durable claim stores the intended body. Send-time validation then
|
||||
// snapshots the intended state immediately before the Matrix request.
|
||||
return this.sendClaimedReply(
|
||||
conversation,
|
||||
state,
|
||||
incomingKey,
|
||||
reply,
|
||||
latest.timestamp,
|
||||
exhausted,
|
||||
requestsMedia,
|
||||
isHybridMediaAsk,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -360,6 +367,7 @@ export class ConversationAutomationService {
|
||||
const claim = this.claims.getClaim?.(conversation.roomId, incomingKey);
|
||||
if (claim?.status === 'pending') return true;
|
||||
if (claim?.status === 'sent') return false;
|
||||
if (claim?.status === 'superseded') return false;
|
||||
// On the first scan after startup, retain Matrix unread as the conservative
|
||||
// bootstrap signal so a deployment cannot revive historical read replies.
|
||||
// Later scans can recover fresh inbound events even if that flag cleared.
|
||||
@@ -376,11 +384,11 @@ export class ConversationAutomationService {
|
||||
conversationId: string,
|
||||
incomingKey: string,
|
||||
reply: string,
|
||||
isLegacy: boolean,
|
||||
requestsMedia: boolean,
|
||||
isHybridMediaAsk: boolean,
|
||||
): boolean {
|
||||
if (state.automation.lastIncomingKey !== incomingKey) {
|
||||
if (isLegacy || isHybridMediaAsk) state.askedForMedia = true;
|
||||
if (requestsMedia) state.askedForMedia = true;
|
||||
if (isHybridMediaAsk) state.automation.hybridPhase = 'post-media-ask';
|
||||
state.automation.turnsUsed += 1;
|
||||
state.automation.lastIncomingKey = incomingKey;
|
||||
@@ -405,33 +413,126 @@ export class ConversationAutomationService {
|
||||
incomingKey: string,
|
||||
reply: string,
|
||||
incomingAt: number,
|
||||
exhausted: boolean,
|
||||
requestsMedia: boolean,
|
||||
isHybridMediaAsk: boolean,
|
||||
signal?: AbortSignal,
|
||||
resumePending = false,
|
||||
): Promise<ConversationAutomationResult | null> {
|
||||
this.claims.markAttempt?.(conversation.roomId!, incomingKey);
|
||||
const sent = await this.rateLimiter.throttle(
|
||||
() => this.chat.sendChatMessage(conversation.url!, reply),
|
||||
(success) => success,
|
||||
const conversationId = conversation.roomId!;
|
||||
const attempt = await this.rateLimiter.throttle(
|
||||
async () => {
|
||||
const latestConversation = await this.loadConversationBeforeSend(conversationId, conversation);
|
||||
const latest = latestConversation ? latestInboundMessage(latestConversation.messages ?? []) : null;
|
||||
const latestKey = latest ? messageKey(latest) : null;
|
||||
const freshState = latestConversation
|
||||
? mergeConversationState(
|
||||
this.states.get(conversationId),
|
||||
buildConversationState((latestConversation.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))),
|
||||
)
|
||||
: this.states.get(conversationId) ?? state;
|
||||
|
||||
const persistedIntentCanResume = resumePending
|
||||
&& freshState.automation.lastIncomingKey === incomingKey
|
||||
&& freshState.automation.status === 'human-required';
|
||||
if (!latestConversation || !latest || latestKey !== incomingKey
|
||||
|| (freshState.automation.status !== 'active' && !persistedIntentCanResume)) {
|
||||
this.claims.markSuperseded?.(conversationId, incomingKey);
|
||||
const result = latestConversation && latest
|
||||
? this.applyFreshHandoff(latestConversation, freshState, latest)
|
||||
: null;
|
||||
if (!result && latestConversation) this.states.save(conversationId, freshState);
|
||||
this.logger.info(
|
||||
`[automation] Superseded queued reply for u/${conversation.username}; a newer conversation state was observed before send`,
|
||||
);
|
||||
return { kind: 'superseded' as const, result, state: freshState };
|
||||
}
|
||||
|
||||
if (freshState.boundaries.length > 0 || hasReceivedImage(latestConversation.messages ?? [])) {
|
||||
this.claims.markSuperseded?.(conversationId, incomingKey);
|
||||
const result = this.applyFreshHandoff(latestConversation, freshState, latest);
|
||||
return { kind: 'superseded' as const, result, state: freshState };
|
||||
}
|
||||
|
||||
if (requestsMedia && freshState.askedForMedia
|
||||
&& !(resumePending && freshState.automation.lastIncomingKey === incomingKey)) {
|
||||
this.claims.markSuperseded?.(conversationId, incomingKey);
|
||||
const result = this.finish(
|
||||
latestConversation,
|
||||
conversationId,
|
||||
freshState,
|
||||
incomingKey,
|
||||
'human-required',
|
||||
'A media request has already been made; further autonomous requests are disabled.',
|
||||
);
|
||||
return { kind: 'superseded' as const, result, state: freshState };
|
||||
}
|
||||
|
||||
const exhausted = this.advanceStateForReply(
|
||||
freshState,
|
||||
conversationId,
|
||||
incomingKey,
|
||||
reply,
|
||||
requestsMedia,
|
||||
isHybridMediaAsk,
|
||||
);
|
||||
this.claims.markAttempt?.(conversationId, incomingKey);
|
||||
const sent = await this.chat.sendChatMessage(latestConversation.url!, reply);
|
||||
return { kind: sent ? 'sent' as const : 'failed' as const, exhausted, state: freshState };
|
||||
},
|
||||
(result) => result.kind === 'sent',
|
||||
signal,
|
||||
REPLY_SEND_PRIORITY,
|
||||
);
|
||||
if (!sent) return null;
|
||||
if (attempt.kind === 'superseded') return attempt.result;
|
||||
if (attempt.kind === 'failed') return null;
|
||||
const sentAt = this.now();
|
||||
this.claims.markSent(conversation.roomId!, incomingKey);
|
||||
this.lastReplySentAt = sentAt;
|
||||
this.lastReplyLatencyMs = Math.max(0, sentAt - incomingAt);
|
||||
this.logger.info(
|
||||
`[automation] Sent ${state.automation.policyMode} turn ${state.automation.turnsUsed}/${state.automation.maxAutomatedTurns} `
|
||||
`[automation] Sent ${attempt.state.automation.policyMode} turn ${attempt.state.automation.turnsUsed}/${attempt.state.automation.maxAutomatedTurns} `
|
||||
+ `to u/${conversation.username}; reply latency ${Math.round(this.lastReplyLatencyMs / 1_000)}s`,
|
||||
);
|
||||
return {
|
||||
conversationId: conversation.roomId!,
|
||||
username: conversation.username,
|
||||
kind: exhausted ? 'human-required' : 'sent',
|
||||
remainingTurns: remainingAutomationTurns(state),
|
||||
kind: attempt.exhausted ? 'human-required' : 'sent',
|
||||
remainingTurns: remainingAutomationTurns(attempt.state),
|
||||
};
|
||||
}
|
||||
|
||||
private async loadConversationBeforeSend(conversationId: string, observed: ActiveChat): Promise<ActiveChat | undefined> {
|
||||
if (this.beforeSendLoader) return this.beforeSendLoader(conversationId, observed);
|
||||
await this.chat.refreshTopChats(REPLY_REFRESH_LIMIT);
|
||||
return (await this.chat.getCachedConversations()).find((item) => item.roomId === conversationId);
|
||||
}
|
||||
|
||||
private applyFreshHandoff(
|
||||
conversation: ActiveChat,
|
||||
state: ConversationState,
|
||||
latest: ChatMessage,
|
||||
): ConversationAutomationResult | null {
|
||||
if (state.boundaries.length > 0) {
|
||||
return this.finish(conversation, conversation.roomId!, state, messageKey(latest), 'boundary', state.boundaries.join('; '));
|
||||
}
|
||||
if (hasReceivedImage(conversation.messages ?? [])) {
|
||||
return this.finish(conversation, conversation.roomId!, state, messageKey(latest), 'image-received', 'The user shared an image.');
|
||||
}
|
||||
const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]);
|
||||
if (sellerSignal) {
|
||||
state.sellerDetected = true;
|
||||
return this.finish(
|
||||
conversation,
|
||||
conversation.roomId!,
|
||||
state,
|
||||
messageKey(latest),
|
||||
'human-required',
|
||||
`Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveConversationBehaviour(subreddit: string | undefined): SubredditConfig['conversation'] {
|
||||
const configured = subreddit
|
||||
? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
export interface BoundaryMessage {
|
||||
text: string;
|
||||
isSelf: boolean;
|
||||
}
|
||||
|
||||
const MEDIA_NOUN = String.raw`(?:pic|pics|picture|pictures|photo|photos|selfie|selfies|snap|snaps|image|images|video|videos|nude|nudes|content)`;
|
||||
const MEDIA_VERB = String.raw`(?:send(?:ing)?|shar(?:e|ing)|show(?:ing)?|giv(?:e|ing)|drop(?:ping)?|swap(?:ping)?|trad(?:e|ing))`;
|
||||
|
||||
const EXPLICIT_MEDIA_REFUSAL_PATTERNS = [
|
||||
new RegExp(String.raw`\b(?:no|not|never|won't|wouldn't|don't|do not|can't|cannot)\b[^.\n]{0,60}\b${MEDIA_NOUN}\b`, 'i'),
|
||||
new RegExp(String.raw`\b(?:won't|wouldn't|don't|do not|not going to|never)\b[^.\n]{0,40}\b${MEDIA_VERB}\b`, 'i'),
|
||||
new RegExp(String.raw`\b(?:zero|no) interest\b[^.\n]{0,60}\b(?:${MEDIA_VERB}|${MEDIA_NOUN}|anything|something)\b`, 'i'),
|
||||
new RegExp(String.raw`\b(?:rather not|not comfortable)\b[^.\n]{0,60}\b(?:${MEDIA_VERB}|${MEDIA_NOUN})\b`, 'i'),
|
||||
];
|
||||
|
||||
const CONTEXTUAL_MEDIA_REFUSAL_PATTERNS = [
|
||||
/^(?:no|nope|nah|no thanks|not really|rather not|pass)(?:,? thanks)?[.!\s]*$/i,
|
||||
/^(?:i(?:'m| am)|im|we(?:'re| are)|we're) good(?: thanks)?[.!\s]*$/i,
|
||||
/\b(?:zero interest|not interested|no interest)\b/i,
|
||||
/\b(?:not sending|won't send|wouldn't send|don't send|do not send)\b/i,
|
||||
];
|
||||
|
||||
const FULL_STOP_PATTERNS = [
|
||||
/\bstop\b/i,
|
||||
/\bleave me alone\b/i,
|
||||
/\b(?:do not|don't) message\b/i,
|
||||
/\bback off\b/i,
|
||||
/^(?:(?:i'm|i am|im)\s+)?not interested[.!\s]*$/i,
|
||||
/\b(?:creepy|uncomfortable|weirdest (?:reddit )?approach)\b/i,
|
||||
];
|
||||
|
||||
export function containsMediaRequest(text: string): boolean {
|
||||
return new RegExp(String.raw`\b${MEDIA_VERB}\b[^.!?\n]{0,60}\b${MEDIA_NOUN}\b`, 'i').test(text)
|
||||
|| new RegExp(String.raw`\b(?:can|could|would|will) you\b[^.!?\n]{0,60}\b(?:${MEDIA_VERB}\b[^.!?\n]{0,30})?\b${MEDIA_NOUN}\b`, 'i').test(text)
|
||||
|| new RegExp(String.raw`\bgot any (?:more )?${MEDIA_NOUN}\b`, 'i').test(text);
|
||||
}
|
||||
|
||||
export function isFullConversationStop(text: string): boolean {
|
||||
return FULL_STOP_PATTERNS.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
export function isMediaRefusal(text: string, mediaWasRequested: boolean): boolean {
|
||||
const normalized = text.replaceAll('’', "'");
|
||||
if (EXPLICIT_MEDIA_REFUSAL_PATTERNS.some((pattern) => pattern.test(normalized))) return true;
|
||||
return mediaWasRequested && CONTEXTUAL_MEDIA_REFUSAL_PATTERNS.some((pattern) => pattern.test(normalized.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect recipient limits in sequence so short phrases such as "I'm good"
|
||||
* only become a media boundary when they directly follow a media request.
|
||||
*/
|
||||
export function detectConversationBoundaries(messages: readonly BoundaryMessage[]): string[] {
|
||||
const boundaries: string[] = [];
|
||||
let lastSelfRequestedMedia = false;
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.isSelf) {
|
||||
lastSelfRequestedMedia = containsMediaRequest(message.text);
|
||||
continue;
|
||||
}
|
||||
if (isMediaRefusal(message.text, lastSelfRequestedMedia)) {
|
||||
boundaries.push('does not want to send photos or videos');
|
||||
}
|
||||
if (isFullConversationStop(message.text)) {
|
||||
boundaries.push('does not want further sexual escalation');
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(boundaries)];
|
||||
}
|
||||
@@ -29,7 +29,12 @@ export function recordConversationReply(
|
||||
deps.outcomes.suppressRecipient(message.recipient, 'boundary');
|
||||
}
|
||||
|
||||
if (outcome === 'engaged' && !deps.successful.existsByMessage(message.message)) {
|
||||
const terminalNegative = outcome === 'boundary' || outcome === 'declined' || outcome === 'upsell';
|
||||
if (terminalNegative || (context.authoritativeTranscript && (outcome !== 'engaged' || !context.learningEligible))) {
|
||||
deps.successful.deleteBySentMessageId(message.id);
|
||||
}
|
||||
|
||||
if (outcome === 'engaged' && context.learningEligible && !deps.successful.existsByMessage(message.message)) {
|
||||
deps.successful.insert({
|
||||
sentMessageId: message.id,
|
||||
originalMessage: message.message,
|
||||
@@ -41,6 +46,8 @@ export function recordConversationReply(
|
||||
deps.logger.info(`Engaged response from u/${message.recipient}; stored for learning.`);
|
||||
} else if (outcome !== 'engaged') {
|
||||
deps.logger.info(`Response from u/${message.recipient} classified as ${outcome}; excluded from learning examples.`);
|
||||
} else if (!context.learningEligible) {
|
||||
deps.logger.info(`Response from u/${message.recipient} is not terminal; deferred from learning examples.`);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isFullConversationStop, isMediaRefusal } from './conversation-boundaries.js';
|
||||
|
||||
export const CONVERSATION_OUTCOMES = ['engaged', 'declined', 'boundary', 'upsell', 'unknown'] as const;
|
||||
export type ConversationOutcome = (typeof CONVERSATION_OUTCOMES)[number];
|
||||
|
||||
@@ -6,6 +8,16 @@ export interface OutcomeContext {
|
||||
messageCount?: number;
|
||||
/** True when both participants have sent at least two messages. */
|
||||
reciprocal?: boolean;
|
||||
/** All recipient messages in the attributed conversation, oldest first. */
|
||||
responses?: readonly string[];
|
||||
/** True only after automation has reached a terminal/human-owned state. */
|
||||
learningEligible?: boolean;
|
||||
/** A received image is an explicit positive outcome once the flow is complete. */
|
||||
receivedImage?: boolean;
|
||||
/** Whether the conversation had already asked the recipient for media. */
|
||||
mediaWasRequested?: boolean;
|
||||
/** True when responses contains the complete attributed recipient transcript. */
|
||||
authoritativeTranscript?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,10 +28,16 @@ export function classifyConversationOutcome(
|
||||
reply: string,
|
||||
context: OutcomeContext = {},
|
||||
): ConversationOutcome {
|
||||
const text = reply.toLowerCase();
|
||||
const responses = context.responses?.length ? context.responses : [reply];
|
||||
const text = responses.join('\n').toLowerCase();
|
||||
if (/\b(onlyfans|fansly|manyvids|link in bio|cash\.?app|venmo)\b/.test(text)) return 'upsell';
|
||||
if (/\b(stop|leave me alone|do not message|don't message)\b/.test(text)) return 'boundary';
|
||||
if (/\b(no thanks|not interested|nope|don't send|do not send)\b/.test(text)) return 'declined';
|
||||
if (context.reciprocal && (context.messageCount ?? 0) >= 4 && text.trim().length > 0) return 'engaged';
|
||||
if (responses.some(isFullConversationStop)) return 'boundary';
|
||||
if (responses.some((response) => isMediaRefusal(response, context.mediaWasRequested === true))
|
||||
|| /\b(no thanks|not interested|nope|nah|rather not|zero interest)\b/.test(text)) return 'declined';
|
||||
const explicitPositive = /\b(yes|yeah|yep|sure|absolutely|definitely|tell me more|sounds (?:good|fun)|i'd love to|would love to)\b/i.test(text);
|
||||
if (context.learningEligible
|
||||
&& (context.receivedImage || (context.reciprocal && (context.messageCount ?? 0) >= 4 && explicitPositive))) {
|
||||
return 'engaged';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isSafeOutboundMessage } from './outbound-message-safety.js';
|
||||
import { containsMediaRequest, detectConversationBoundaries } from './conversation-boundaries.js';
|
||||
|
||||
export interface ConversationMessage {
|
||||
sender: string;
|
||||
@@ -38,7 +39,7 @@ export interface ConversationState {
|
||||
|
||||
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
|
||||
export const DEFAULT_HYBRID_RAPPORT_TURNS = 3;
|
||||
export const CONVERSATION_POLICY_VERSION = 2;
|
||||
export const CONVERSATION_POLICY_VERSION = 3;
|
||||
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
|
||||
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
|
||||
|
||||
@@ -103,11 +104,6 @@ export function buildConversationState(messages: ConversationMessage[]): Convers
|
||||
.filter((message) => !message.isSelf)
|
||||
.map((message) => message.text)
|
||||
.join('\n');
|
||||
const ownText = messages
|
||||
.filter((message) => message.isSelf)
|
||||
.map((message) => message.text)
|
||||
.join('\n');
|
||||
|
||||
const ageMatch = firstSelfMessage.match(/\b(3[0-5])\s*[mM]\b/);
|
||||
const persona: ConversationPersona = {
|
||||
age: ageMatch ? Number(ageMatch[1]) : EMPTY_PERSONA.age,
|
||||
@@ -115,20 +111,14 @@ export function buildConversationState(messages: ConversationMessage[]): Convers
|
||||
relationshipStatus: /\bmarried\b|\bwife\b/i.test(firstSelfMessage) ? 'married' : EMPTY_PERSONA.relationshipStatus,
|
||||
};
|
||||
|
||||
const boundaries: string[] = [];
|
||||
if (/\b(no|not)\b[^.\n]{0,50}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b|\bdon't send\b[^.\n]{0,30}\b(pic|photo|video|nudes?)\b/i.test(theirText)) {
|
||||
boundaries.push('does not want to send photos or videos');
|
||||
}
|
||||
if (/\bstop\b|\bleave me alone\b|\bnot interested\b/i.test(theirText)) {
|
||||
boundaries.push('does not want further sexual escalation');
|
||||
}
|
||||
const boundaries = detectConversationBoundaries(messages);
|
||||
|
||||
const topics = extractTopics(theirText);
|
||||
return {
|
||||
persona,
|
||||
boundaries,
|
||||
sellerDetected: false,
|
||||
askedForMedia: /\b(send|show|share)\b[^.\n]{0,40}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b/i.test(ownText),
|
||||
askedForMedia: messages.some((message) => message.isSelf && containsMediaRequest(message.text)),
|
||||
topics,
|
||||
automation: createConversationAutomationState(),
|
||||
};
|
||||
@@ -214,14 +204,13 @@ export function isSafeConversationReply(message: string): boolean {
|
||||
/** R4R automation may converse and flirt, but it must never solicit sexual media. */
|
||||
export function isSafeR4RReply(message: string): boolean {
|
||||
if (!isSafeConversationReply(message)) return false;
|
||||
return !/\b(send|show|share|swap|trade|drop|give|dm)\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes|content)\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);
|
||||
return !containsMediaRequest(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 (!containsMediaRequest(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);
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ async function buildRuntime(
|
||||
const chatService = new ChatService(
|
||||
matrixClient, logger, successfulMessagesRepo, mediaService,
|
||||
new MatrixTokenRefresher(), usernameResolver, chatCache,
|
||||
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo,
|
||||
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo, conversationStateRepo,
|
||||
);
|
||||
await chatService.loadCache();
|
||||
const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth);
|
||||
|
||||
Reference in new issue
Block a user