feat: automate bounded image-sharing conversations
This commit is contained in:
1 parent
dcf57273e2
commit
3beb8aada2
15 files changed
+477
-19
No files matched your search
@@ -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']) {
|
||||
|
||||
Reference in new issue
Block a user