feat: label AI-generated conversation messages
This commit is contained in:
1 parent
3beb8aada2
commit
5ad7f1bf77
10 files changed
+69
-11
No files matched your search
@@ -32,6 +32,7 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
|
|||||||
<strong>{message.isSelf
|
<strong>{message.isSelf
|
||||||
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
|
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
|
||||||
: <RedditUsernameLink username={conversation.username} />}</strong>
|
: <RedditUsernameLink username={conversation.username} />}</strong>
|
||||||
|
{message.isAi ? <span aria-label="Sent by AI" className="conversation-ai-badge">AI</span> : null}
|
||||||
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
|
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
|
||||||
</div>
|
</div>
|
||||||
<p>{message.text}</p>
|
<p>{message.text}</p>
|
||||||
|
|||||||
@@ -123,3 +123,17 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.conversation-ai-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.25rem;
|
||||||
|
padding: 0.125rem 0.4rem;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
|
color: #5558c9;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export const ChatMessageSchema = z.strictObject({
|
|||||||
text: z.string(),
|
text: z.string(),
|
||||||
timestamp: z.number(),
|
timestamp: z.number(),
|
||||||
isSelf: z.boolean(),
|
isSelf: z.boolean(),
|
||||||
|
isAi: z.boolean().optional(),
|
||||||
media: z.array(ChatMediaSchema).optional(),
|
media: z.array(ChatMediaSchema).optional(),
|
||||||
});
|
});
|
||||||
export const ActiveChatSchema = z.strictObject({
|
export const ActiveChatSchema = z.strictObject({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { ChatService } from './chat/service.js';
|
|||||||
import {
|
import {
|
||||||
buildConversationState,
|
buildConversationState,
|
||||||
chooseConversationIntent,
|
chooseConversationIntent,
|
||||||
|
conversationMessageFingerprint,
|
||||||
MAX_AUTOMATED_CONVERSATION_TURNS,
|
MAX_AUTOMATED_CONVERSATION_TURNS,
|
||||||
mergeConversationState,
|
mergeConversationState,
|
||||||
remainingAutomationTurns,
|
remainingAutomationTurns,
|
||||||
@@ -101,6 +102,7 @@ export class ConversationAutomationService {
|
|||||||
state.askedForMedia = true;
|
state.askedForMedia = true;
|
||||||
state.automation.turnsUsed += 1;
|
state.automation.turnsUsed += 1;
|
||||||
state.automation.lastIncomingKey = incomingKey;
|
state.automation.lastIncomingKey = incomingKey;
|
||||||
|
state.automation.aiMessageFingerprints.push(conversationMessageFingerprint(reply));
|
||||||
const exhausted = state.automation.turnsUsed >= MAX_AUTOMATED_CONVERSATION_TURNS;
|
const exhausted = state.automation.turnsUsed >= MAX_AUTOMATED_CONVERSATION_TURNS;
|
||||||
if (exhausted) {
|
if (exhausted) {
|
||||||
state.automation.status = 'human-required';
|
state.automation.status = 'human-required';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
export interface ConversationMessage {
|
export interface ConversationMessage {
|
||||||
sender: string;
|
sender: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -45,6 +47,8 @@ export interface ConversationAutomationState {
|
|||||||
status: ConversationAutomationStatus;
|
status: ConversationAutomationStatus;
|
||||||
lastIncomingKey: string | null;
|
lastIncomingKey: string | null;
|
||||||
handoffReason: string | null;
|
handoffReason: string | null;
|
||||||
|
/** SHA-256 fingerprints avoid storing a second copy of generated message text. */
|
||||||
|
aiMessageFingerprints: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createConversationAutomationState(): ConversationAutomationState {
|
export function createConversationAutomationState(): ConversationAutomationState {
|
||||||
@@ -53,9 +57,14 @@ export function createConversationAutomationState(): ConversationAutomationState
|
|||||||
status: 'active',
|
status: 'active',
|
||||||
lastIncomingKey: null,
|
lastIncomingKey: null,
|
||||||
handoffReason: null,
|
handoffReason: null,
|
||||||
|
aiMessageFingerprints: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function conversationMessageFingerprint(message: string): string {
|
||||||
|
return createHash('sha256').update(message.trim()).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
export function remainingAutomationTurns(state: ConversationState): number {
|
export function remainingAutomationTurns(state: ConversationState): number {
|
||||||
if (state.automation.status !== 'active') return 0;
|
if (state.automation.status !== 'active') return 0;
|
||||||
return Math.max(0, MAX_AUTOMATED_CONVERSATION_TURNS - state.automation.turnsUsed);
|
return Math.max(0, MAX_AUTOMATED_CONVERSATION_TURNS - state.automation.turnsUsed);
|
||||||
@@ -222,6 +231,9 @@ function parseAutomationState(value: unknown): ConversationAutomationState {
|
|||||||
status,
|
status,
|
||||||
lastIncomingKey: typeof parsed.lastIncomingKey === 'string' ? parsed.lastIncomingKey : null,
|
lastIncomingKey: typeof parsed.lastIncomingKey === 'string' ? parsed.lastIncomingKey : null,
|
||||||
handoffReason: typeof parsed.handoffReason === 'string' ? parsed.handoffReason : null,
|
handoffReason: typeof parsed.handoffReason === 'string' ? parsed.handoffReason : null,
|
||||||
|
aiMessageFingerprints: Array.isArray(parsed.aiMessageFingerprints)
|
||||||
|
? parsed.aiMessageFingerprints.filter((item): item is string => typeof item === 'string')
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { WebContext } from '../context.js';
|
|||||||
import {
|
import {
|
||||||
buildConversationState,
|
buildConversationState,
|
||||||
chooseConversationIntent,
|
chooseConversationIntent,
|
||||||
|
conversationMessageFingerprint,
|
||||||
mergeConversationState,
|
mergeConversationState,
|
||||||
remainingAutomationTurns,
|
remainingAutomationTurns,
|
||||||
} from '../../services/conversation-state.js';
|
} from '../../services/conversation-state.js';
|
||||||
@@ -169,12 +170,14 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
sentAt: number;
|
sentAt: number;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
let automatedOpener: string | null = null;
|
||||||
try {
|
try {
|
||||||
const messagesRepo = resolved.runtime?.resources.messagesRepo ?? ctx.repos.messages;
|
const messagesRepo = resolved.runtime?.resources.messagesRepo ?? ctx.repos.messages;
|
||||||
const sent = conversation.roomId
|
const sent = conversation.roomId
|
||||||
? messagesRepo.getByChatRoomId(conversation.roomId)
|
? messagesRepo.getByChatRoomId(conversation.roomId)
|
||||||
?? messagesRepo.getLatestByRecipient(conversation.username)
|
?? messagesRepo.getLatestByRecipient(conversation.username)
|
||||||
: messagesRepo.getLatestByRecipient(conversation.username);
|
: messagesRepo.getLatestByRecipient(conversation.username);
|
||||||
|
automatedOpener = sent?.message ?? null;
|
||||||
if (sent && sent.postId) {
|
if (sent && sent.postId) {
|
||||||
const url = sent.postUrl ?? (sent.subreddit
|
const url = sent.postUrl ?? (sent.subreddit
|
||||||
? `https://www.reddit.com/r/${sent.subreddit}/comments/${sent.postId}/`
|
? `https://www.reddit.com/r/${sent.subreddit}/comments/${sent.postId}/`
|
||||||
@@ -205,9 +208,25 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
intelligence.state.automation.handoffReason = intelligence.state.boundaries.join('; ');
|
intelligence.state.automation.handoffReason = intelligence.state.boundaries.join('; ');
|
||||||
}
|
}
|
||||||
if (conversation.roomId) stateRepo?.save(conversation.roomId, intelligence.state);
|
if (conversation.roomId) stateRepo?.save(conversation.roomId, intelligence.state);
|
||||||
|
const aiFingerprints = new Map<string, number>();
|
||||||
|
if (automatedOpener) {
|
||||||
|
aiFingerprints.set(conversationMessageFingerprint(automatedOpener), 1);
|
||||||
|
}
|
||||||
|
for (const fingerprint of intelligence.state.automation.aiMessageFingerprints) {
|
||||||
|
aiFingerprints.set(fingerprint, (aiFingerprints.get(fingerprint) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const annotatedMessages = conversation.messages?.map((message) => {
|
||||||
|
if (!message.isSelf) return message;
|
||||||
|
const fingerprint = conversationMessageFingerprint(message.text);
|
||||||
|
const remainingMatches = aiFingerprints.get(fingerprint) ?? 0;
|
||||||
|
if (remainingMatches === 0) return message;
|
||||||
|
aiFingerprints.set(fingerprint, remainingMatches - 1);
|
||||||
|
return { ...message, isAi: true };
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...conversation,
|
...conversation,
|
||||||
|
...(annotatedMessages ? { messages: annotatedMessages } : {}),
|
||||||
owner: resolved.runtime
|
owner: resolved.runtime
|
||||||
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
|
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
|
||||||
: currentOwner(),
|
: currentOwner(),
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ describe('conversation automation', () => {
|
|||||||
expect(third).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
expect(third).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
||||||
expect(chat.sendChatMessage).toHaveBeenCalledTimes(3);
|
expect(chat.sendChatMessage).toHaveBeenCalledTimes(3);
|
||||||
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 3, status: 'human-required' });
|
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 3, status: 'human-required' });
|
||||||
|
expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(3);
|
||||||
expect(await service.processConversation(incoming(3))).toBeNull();
|
expect(await service.processConversation(incoming(3))).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function detailFixture(chat = alice) {
|
|||||||
return {
|
return {
|
||||||
...chat,
|
...chat,
|
||||||
messages: [
|
messages: [
|
||||||
{ sender: 'me', text: 'Hello Alice', timestamp: 1_785_999_900_000, isSelf: true },
|
{ sender: 'me', text: 'Hello Alice', timestamp: 1_785_999_900_000, isSelf: true, isAi: true },
|
||||||
{ sender: 'alice', text: 'Good to hear from you', timestamp: 1_786_000_000_000, isSelf: false },
|
{ sender: 'alice', text: 'Good to hear from you', timestamp: 1_786_000_000_000, isSelf: false },
|
||||||
],
|
],
|
||||||
triggeringPost: {
|
triggeringPost: {
|
||||||
@@ -136,6 +136,7 @@ describe('ConversationsPage', () => {
|
|||||||
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
|
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
|
||||||
}
|
}
|
||||||
expect(screen.getByText('Good to hear from you')).toBeInTheDocument();
|
expect(screen.getByText('Good to hear from you')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Sent by AI')).toHaveTextContent('AI');
|
||||||
expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1'));
|
expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1'));
|
||||||
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveAttribute(
|
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveAttribute(
|
||||||
'src',
|
'src',
|
||||||
|
|||||||
@@ -574,7 +574,7 @@ describe('OpenRouterClient', () => {
|
|||||||
boundaries: [],
|
boundaries: [],
|
||||||
askedForMedia: false,
|
askedForMedia: false,
|
||||||
topics: [],
|
topics: [],
|
||||||
automation: { turnsUsed: 0, status: 'active', lastIncomingKey: null, handoffReason: null },
|
automation: { turnsUsed: 0, status: 'active', lastIncomingKey: null, handoffReason: null, aiMessageFingerprints: [] },
|
||||||
},
|
},
|
||||||
{ kind: 'answer-question', reason: 'their latest message asks a direct question' },
|
{ kind: 'answer-question', reason: 'their latest message asks a direct question' },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AddressInfo } from 'node:net';
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { WebContext } from '../../src/web/context.js';
|
import type { WebContext } from '../../src/web/context.js';
|
||||||
import { createConversationsRouter } from '../../src/web/routes/conversations.js';
|
import { createConversationsRouter } from '../../src/web/routes/conversations.js';
|
||||||
|
import { buildConversationState, conversationMessageFingerprint } from '../../src/services/conversation-state.js';
|
||||||
|
|
||||||
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
||||||
|
|
||||||
@@ -234,13 +235,14 @@ describe('conversations route', () => {
|
|||||||
lastMessageSnippet: 'Hello',
|
lastMessageSnippet: 'Hello',
|
||||||
isFromUs: false,
|
isFromUs: false,
|
||||||
unread: false,
|
unread: false,
|
||||||
messages: [{
|
messages: [
|
||||||
sender: 'alice',
|
{ sender: 'me', text: 'Automated hello', timestamp: 1_785_999_900_000, isSelf: true },
|
||||||
text: 'Hello',
|
{ sender: 'alice', text: 'Hello', timestamp: 1_786_000_000_000, isSelf: false },
|
||||||
timestamp: 1_786_000_000_000,
|
],
|
||||||
isSelf: false,
|
|
||||||
}],
|
|
||||||
};
|
};
|
||||||
|
const savedState = buildConversationState(conversation.messages);
|
||||||
|
savedState.automation.turnsUsed = 1;
|
||||||
|
savedState.automation.aiMessageFingerprints = [conversationMessageFingerprint('Automated hello')];
|
||||||
const refreshTopChats = vi.fn();
|
const refreshTopChats = vi.fn();
|
||||||
const loadConversationDetails = vi.fn();
|
const loadConversationDetails = vi.fn();
|
||||||
const context = {
|
const context = {
|
||||||
@@ -249,6 +251,7 @@ describe('conversations route', () => {
|
|||||||
messages: { getLatestByRecipient: vi.fn().mockReturnValue(undefined) },
|
messages: { getLatestByRecipient: vi.fn().mockReturnValue(undefined) },
|
||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
|
conversationState: { get: vi.fn().mockReturnValue(savedState), save: vi.fn() },
|
||||||
chat: {
|
chat: {
|
||||||
getSyncStatus: vi.fn().mockReturnValue({
|
getSyncStatus: vi.fn().mockReturnValue({
|
||||||
syncing: false,
|
syncing: false,
|
||||||
@@ -270,11 +273,13 @@ describe('conversations route', () => {
|
|||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
expect(body.username).toBe('alice');
|
expect(body.username).toBe('alice');
|
||||||
expect(body.automation).toEqual({
|
expect(body.automation).toEqual({
|
||||||
turnsUsed: 0,
|
turnsUsed: 1,
|
||||||
remainingTurns: 3,
|
remainingTurns: 2,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
handoffReason: null,
|
handoffReason: null,
|
||||||
});
|
});
|
||||||
|
expect(body.messages[0]).toMatchObject({ text: 'Automated hello', isAi: true });
|
||||||
|
expect(body.messages[1].isAi).toBeUndefined();
|
||||||
expect(refreshTopChats).not.toHaveBeenCalled();
|
expect(refreshTopChats).not.toHaveBeenCalled();
|
||||||
expect(loadConversationDetails).not.toHaveBeenCalled();
|
expect(loadConversationDetails).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -310,10 +315,12 @@ describe('conversations route', () => {
|
|||||||
const response = await request(app, '/api/conversations/room-alice');
|
const response = await request(app, '/api/conversations/room-alice');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect((await response.json()).triggeringPost).toEqual({
|
const body = await response.json();
|
||||||
|
expect(body.triggeringPost).toEqual({
|
||||||
id: 'post-exact', subreddit: 'example', title: 'Exact source post',
|
id: 'post-exact', subreddit: 'example', title: 'Exact source post',
|
||||||
url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, sentAt: 9,
|
url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, sentAt: 9,
|
||||||
});
|
});
|
||||||
|
expect(body.messages[0]).toMatchObject({ text: 'Hello', isAi: true });
|
||||||
expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled();
|
expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user