Files
RedditBot/src/services/conversation-state.ts
T

179 lines
6.7 KiB
TypeScript

export interface ConversationMessage {
sender: string;
text: string;
isSelf: boolean;
}
export type ConversationIntentKind =
| 'answer-question'
| 'respect-boundary'
| 'reciprocate'
| 'follow-up'
| 'media-ask';
export interface ConversationIntent {
kind: ConversationIntentKind;
reason: string;
}
export interface ConversationPersona {
age: number | null;
location: string | null;
relationshipStatus: 'married' | 'single' | 'unspecified';
}
export interface ConversationState {
persona: ConversationPersona;
boundaries: string[];
askedForMedia: boolean;
topics: string[];
}
const EMPTY_PERSONA: ConversationPersona = {
age: null,
location: null,
relationshipStatus: 'unspecified',
};
export function buildConversationState(messages: ConversationMessage[]): ConversationState {
const firstSelfMessage = messages.find((message) => message.isSelf)?.text ?? '';
const theirText = messages
.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,
location: /\bUK\b/i.test(firstSelfMessage) ? 'UK' : EMPTY_PERSONA.location,
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 topics = extractTopics(theirText);
return {
persona,
boundaries,
askedForMedia: /\b(send|show|share)\b[^.\n]{0,40}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b/i.test(ownText),
topics,
};
}
export function chooseConversationIntent(
messages: ConversationMessage[],
state: ConversationState,
requestedIntent?: 'media-ask',
): ConversationIntent {
if (requestedIntent === 'media-ask') {
if (state.boundaries.includes('does not want to send photos or videos')) {
return { kind: 'respect-boundary', reason: 'they have declined or set a boundary around photos or videos' };
}
if (state.askedForMedia) {
return { kind: 'follow-up', reason: 'a media ask was already made, so do not repeat it' };
}
return { kind: 'media-ask', reason: 'a media ask was explicitly requested and no boundary is recorded' };
}
if (state.boundaries.length > 0) {
return { kind: 'respect-boundary', reason: 'their conversation contains a stated boundary' };
}
const latestOtherMessage = [...messages].reverse().find((message) => !message.isSelf)?.text ?? '';
if (/\?\s*$|\b(what|why|where|when|who|how|are you|do you|would you|can you)\b/i.test(latestOtherMessage)) {
return { kind: 'answer-question', reason: 'their latest message asks a direct question' };
}
if (latestOtherMessage.trim().length > 0) {
return { kind: 'reciprocate', reason: 'they shared something to acknowledge before advancing the conversation' };
}
return { kind: 'follow-up', reason: 'there is no substantive reply to build on' };
}
/** Merge durable memory with observations from the currently loaded transcript. */
export function mergeConversationState(
saved: ConversationState | undefined,
observed: ConversationState,
): ConversationState {
if (!saved) return observed;
return {
persona: {
age: observed.persona.age ?? saved.persona.age,
location: observed.persona.location ?? saved.persona.location,
relationshipStatus: observed.persona.relationshipStatus !== 'unspecified'
? observed.persona.relationshipStatus
: saved.persona.relationshipStatus,
},
boundaries: [...new Set([...saved.boundaries, ...observed.boundaries])],
askedForMedia: saved.askedForMedia || observed.askedForMedia,
topics: [...new Set([...saved.topics, ...observed.topics])],
};
}
/**
* Conversation text is deliberately delimited as data. It must never override
* the system instruction, even if a participant includes instruction-like text.
*/
export function formatConversationAsUntrustedData(messages: ConversationMessage[], maxMessages = 24): string {
const recentMessages = messages.slice(-maxMessages);
const rows = recentMessages.map((message) => {
const speaker = message.isSelf ? 'You' : 'Other person';
return `${speaker}: ${message.text}`;
});
return `BEGIN UNTRUSTED CONVERSATION DATA\n${rows.join('\n')}\nEND UNTRUSTED CONVERSATION DATA`;
}
/** Reject obvious instruction leakage and outputs that disregard the reply length contract. */
export function isSafeConversationReply(message: string): boolean {
const trimmed = message.trim();
if (!trimmed || trimmed.length > 600) return false;
if (/\b(as an ai|system prompt|begin untrusted|end untrusted|ignore (all|previous) instructions)\b/i.test(trimmed)) return false;
const sentences = trimmed.match(/[^.!?]+[.!?]+/g) ?? [];
return sentences.length <= 3;
}
export function serializeConversationState(state: ConversationState): string {
return JSON.stringify(state);
}
export function parseConversationState(value: string | null | undefined): ConversationState | null {
if (!value) return null;
try {
const parsed = JSON.parse(value) as Partial<ConversationState>;
if (!parsed.persona || !Array.isArray(parsed.boundaries) || !Array.isArray(parsed.topics) || typeof parsed.askedForMedia !== 'boolean') {
return null;
}
return {
persona: {
age: typeof parsed.persona.age === 'number' ? parsed.persona.age : null,
location: typeof parsed.persona.location === 'string' ? parsed.persona.location : null,
relationshipStatus: parsed.persona.relationshipStatus === 'married' || parsed.persona.relationshipStatus === 'single'
? parsed.persona.relationshipStatus
: 'unspecified',
},
boundaries: parsed.boundaries.filter((value): value is string => typeof value === 'string'),
askedForMedia: parsed.askedForMedia,
topics: parsed.topics.filter((value): value is string => typeof value === 'string'),
};
} catch {
return null;
}
}
function extractTopics(text: string): string[] {
const topics: string[] = [];
for (const topic of ['music', 'films', 'movies', 'gaming', 'work', 'travel', 'fitness']) {
if (new RegExp(`\\b${topic}\\b`, 'i').test(text)) topics.push(topic);
}
return topics;
}