Fix conversation outcome learning ingestion

This commit is contained in:
jjbrunton committed 2026-08-09 08:17:56 +01:00
1 parent a85b99ceb6
commit 55a6774daf
3 files changed
+249 -52

No files matched your search

+68 -48
View File
@@ -480,12 +480,14 @@ export class ChatService {
if (!lastFullSyncAt || this.cache.get().length === 0) {
this.logger.info('[chat] startup: no prior full sync recorded → running full backfill');
const chats = await this.fetchAllMessages();
this.backfillConversationOutcomes();
return { kind: 'full', chats: chats.length };
}
this.logger.info(
`[chat] startup: full sync was ${new Date(lastFullSyncAt).toISOString()} → running delta`,
);
const chats = await this.getActiveChats();
this.backfillConversationOutcomes();
return { kind: 'delta', chats: chats.length };
}
@@ -772,11 +774,6 @@ export class ChatService {
if (this.mediaService && scraped.some((message) => message.mediaUrls.length > 0)) {
try {
newMessages = await this.mediaService.attachMedia(chat, scraped);
if (newMessages.some(
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
)) {
this.recordReceivedImage(chat);
}
} catch (err) {
this.logger.warn(
`[chat] delta media attach failed for ${roomId}: ${(err as Error).message}`,
@@ -784,6 +781,7 @@ export class ChatService {
}
}
this.mergeNewMessages(chat, newMessages);
this.recordConversationPerformance(chat);
}
const merged = Array.from(existing.values()).sort(
@@ -856,8 +854,8 @@ export class ChatService {
}
private mergeNewMessages(chat: ActiveChat, newMessages: ChatMessage[]): void {
if (!chat.messages || chat.messages.length === 0) return;
chat.messages = this.normalizeMessages([...chat.messages, ...newMessages]);
if (newMessages.length === 0) return;
chat.messages = this.normalizeMessages([...(chat.messages ?? []), ...newMessages]);
}
/**
@@ -1005,43 +1003,65 @@ export class ChatService {
const firstSelf = chat.messages.find((m) => m.isSelf);
if (firstSelf) chat.originMessage = firstSelf.text;
const hasReply = chat.messages.some((m) => !m.isSelf);
const hasReceivedImage = chat.messages.some(
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
);
let performanceMessage: SentMessageRecord | undefined;
let firstAttributedReply = false;
this.recordConversationPerformance(chat);
}
if (firstSelf && chat.roomId && this.messagesRepo) {
try {
const existing = this.messagesRepo.getByChatRoomId(chat.roomId);
const hasPerformanceContext = Boolean(
existing?.postId || existing?.strategyId || existing?.subreddit,
);
if (existing && hasPerformanceContext) {
performanceMessage = existing;
if (hasReply && !existing.gotResponse) {
firstAttributedReply = true;
}
if (hasReceivedImage && !existing.gotImage) {
this.messagesRepo.markGotImage(existing.id);
}
}
} catch (err) {
this.logger.warn('[chat] failed to associate reply with performance context:', err);
}
/**
* Project a cached or newly-synced conversation into durable performance
* state. Safe to call repeatedly: outcome rows are upserted and successful
* examples are de-duplicated by their original message.
*/
private recordConversationPerformance(chat: ActiveChat): boolean {
const messages = chat.messages ?? [];
if (!chat.roomId || !this.messagesRepo) return false;
let performanceMessage: SentMessageRecord | undefined;
try {
const existing = this.messagesRepo.getByChatRoomId(chat.roomId);
const hasPerformanceContext = Boolean(
existing?.postId || existing?.strategyId || existing?.subreddit,
);
if (existing && hasPerformanceContext) performanceMessage = existing;
} catch (err) {
this.logger.warn('[chat] failed to associate reply with performance context:', err);
return false;
}
if (!performanceMessage) return false;
const firstSelf = messages.find(
(message) => message.isSelf && message.timestamp >= performanceMessage.sentAt - 1_000,
);
const conversationStart = firstSelf?.timestamp ?? performanceMessage.sentAt;
// Ignore messages from an older exchange in a reused room. The one-second
// tolerance accommodates the optimistic opener timestamp being reconciled
// with Matrix's authoritative event timestamp. Legacy caches can omit the
// opener, so the durable sent timestamp is the fallback boundary.
const conversationMessages = messages.filter(
(message) => message.timestamp >= conversationStart - 1_000,
);
const incomingMessages = conversationMessages.filter((message) => !message.isSelf);
const hasReply = incomingMessages.length > 0;
const hasReceivedImage = incomingMessages.some(
(message) => (message.media?.length ?? 0) > 0,
);
const firstAttributedReply = hasReply && !performanceMessage.gotResponse;
if (hasReceivedImage && !performanceMessage.gotImage) {
this.messagesRepo.markGotImage(performanceMessage.id);
}
if (firstSelf && hasReply && performanceMessage && this.successfulMessagesRepo && this.outcomesRepo && this.messagesRepo) {
const firstReply = chat.messages.find((m) => !m.isSelf);
const selfCount = chat.messages.filter((m) => m.isSelf).length;
const otherCount = chat.messages.length - selfCount;
if (hasReply && this.successfulMessagesRepo && this.outcomesRepo) {
const firstReply = incomingMessages[0];
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;
try {
recordConversationReply(
performanceMessage,
firstReply?.text.slice(0, 200) ?? '',
{
messageCount: chat.messages.length,
messageCount: selfCount + otherCount,
reciprocal: selfCount >= 2 && otherCount >= 2,
},
{
@@ -1065,24 +1085,24 @@ export class ChatService {
}
} catch (err) {
this.logger.warn('[chat] failed to record conversation outcome:', err);
return false;
}
} else if (hasReply && performanceMessage && this.messagesRepo && !performanceMessage.gotResponse) {
return wasMissingOutcome;
} else if (hasReply && !performanceMessage.gotResponse) {
this.messagesRepo.markGotResponse(performanceMessage.id);
this.logger.warn('[chat] response recorded without canonical outcome repository; excluded from learning');
}
return false;
}
private recordReceivedImage(chat: ActiveChat): void {
if (!chat.roomId || !this.messagesRepo) return;
try {
const existing = this.messagesRepo.getByChatRoomId(chat.roomId);
const hasPerformanceContext = Boolean(
existing?.postId || existing?.strategyId || existing?.subreddit,
);
if (!existing || !hasPerformanceContext) return;
if (!existing.gotImage) this.messagesRepo.markGotImage(existing.id);
} catch (err) {
this.logger.warn('[chat] failed to associate received image with performance context:', err);
/** Repair cached replies that predate canonical outcome recording. */
private backfillConversationOutcomes(): void {
let repaired = 0;
for (const chat of this.cache.get()) {
if (this.recordConversationPerformance(chat)) repaired += 1;
}
if (repaired > 0) {
this.logger.info(`[chat] backfilled ${repaired} missing conversation outcome(s)`);
}
}