feat: automate bounded image-sharing conversations

This commit is contained in:
jjbrunton committed 2026-08-08 23:10:52 +01:00
1 parent dcf57273e2
commit 3beb8aada2
15 files changed
+477 -19

No files matched your search

+59 -3
View File
@@ -25,8 +25,40 @@ export interface ConversationPersona {
export interface ConversationState {
persona: ConversationPersona;
boundaries: string[];
/** Kept for backwards compatibility with state written before automation. */
askedForMedia: boolean;
topics: string[];
automation: ConversationAutomationState;
}
export const MAX_AUTOMATED_CONVERSATION_TURNS = 3;
export type ConversationAutomationStatus =
| 'active'
| 'image-received'
| 'human-required'
| 'boundary'
| 'user-overridden';
export interface ConversationAutomationState {
turnsUsed: number;
status: ConversationAutomationStatus;
lastIncomingKey: string | null;
handoffReason: string | null;
}
export function createConversationAutomationState(): ConversationAutomationState {
return {
turnsUsed: 0,
status: 'active',
lastIncomingKey: null,
handoffReason: null,
};
}
export function remainingAutomationTurns(state: ConversationState): number {
if (state.automation.status !== 'active') return 0;
return Math.max(0, MAX_AUTOMATED_CONVERSATION_TURNS - state.automation.turnsUsed);
}
const EMPTY_PERSONA: ConversationPersona = {
@@ -67,6 +99,7 @@ export function buildConversationState(messages: ConversationMessage[]): Convers
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,
automation: createConversationAutomationState(),
};
}
@@ -79,10 +112,13 @@ export function chooseConversationIntent(
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' };
if (state.automation.status !== 'active' || remainingAutomationTurns(state) === 0) {
return { kind: 'follow-up', reason: 'the automated conversation has reached human handoff' };
}
return { kind: 'media-ask', reason: 'a media ask was explicitly requested and no boundary is recorded' };
return {
kind: 'media-ask',
reason: `image-sharing turn ${state.automation.turnsUsed + 1} of ${MAX_AUTOMATED_CONVERSATION_TURNS}`,
};
}
if (state.boundaries.length > 0) {
@@ -116,6 +152,7 @@ export function mergeConversationState(
boundaries: [...new Set([...saved.boundaries, ...observed.boundaries])],
askedForMedia: saved.askedForMedia || observed.askedForMedia,
topics: [...new Set([...saved.topics, ...observed.topics])],
automation: saved.automation,
};
}
@@ -152,6 +189,7 @@ export function parseConversationState(value: string | null | undefined): Conver
if (!parsed.persona || !Array.isArray(parsed.boundaries) || !Array.isArray(parsed.topics) || typeof parsed.askedForMedia !== 'boolean') {
return null;
}
const automation = parseAutomationState(parsed.automation);
return {
persona: {
age: typeof parsed.persona.age === 'number' ? parsed.persona.age : null,
@@ -163,12 +201,30 @@ export function parseConversationState(value: string | null | undefined): Conver
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'),
automation,
};
} catch {
return null;
}
}
function parseAutomationState(value: unknown): ConversationAutomationState {
if (!value || typeof value !== 'object') return createConversationAutomationState();
const parsed = value as Partial<ConversationAutomationState>;
const status: ConversationAutomationStatus =
parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary' || parsed.status === 'user-overridden'
? parsed.status
: 'active';
return {
turnsUsed: typeof parsed.turnsUsed === 'number'
? Math.max(0, Math.min(MAX_AUTOMATED_CONVERSATION_TURNS, Math.trunc(parsed.turnsUsed)))
: 0,
status,
lastIncomingKey: typeof parsed.lastIncomingKey === 'string' ? parsed.lastIncomingKey : null,
handoffReason: typeof parsed.handoffReason === 'string' ? parsed.handoffReason : null,
};
}
function extractTopics(text: string): string[] {
const topics: string[] = [];
for (const topic of ['music', 'films', 'movies', 'gaming', 'work', 'travel', 'fitness']) {