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

139 lines
5.5 KiB
TypeScript

import { createHash } from 'node:crypto';
import type {
ConversationEventRepository,
AttributionConfidence,
ConversationEventKind,
} from '../database/conversation-event-repository.js';
import type { ConversationAutomationClaimRecord, SentMessageRecord } from '../database/repositories.js';
import type { MediaLibrarySend } from './media-library-types.js';
import type { ChatMessage } from './chat/types.js';
import { conversationMessageFingerprint } from './conversation-state.js';
import type { AttributedConversationMessage } from './conversation-learning-types.js';
export interface ConversationAttributionInput {
conversationId: string;
messages: readonly ChatMessage[];
opener: SentMessageRecord | undefined;
claims: readonly ConversationAutomationClaimRecord[];
mediaSends: readonly MediaLibrarySend[];
}
/**
* Deterministically attributes a Matrix timeline. Event IDs win over all
* fingerprint fallbacks; remaining self-authored events are human-owned with
* inferred confidence, matching the automation safety boundary.
*/
export function attributeConversationMessages(
input: ConversationAttributionInput,
repository?: ConversationEventRepository,
observedAt = Date.now(),
): AttributedConversationMessage[] {
const claimsByEventId = new Map(input.claims.flatMap((claim) =>
claim.matrixEventId ? [[claim.matrixEventId, claim] as const] : []));
const claimsByFingerprint = new Map(input.claims.flatMap((claim) =>
claim.replyText ? [[conversationMessageFingerprint(claim.replyText), claim] as const] : []));
const mediaByEventId = new Map<string, { send: MediaLibrarySend; kind: ConversationEventKind }>();
for (const send of input.mediaSends) {
if (send.matrixMediaEventId) {
mediaByEventId.set(send.matrixMediaEventId, {
send,
kind: send.initiatedBy === 'human' ? 'manual-library-media' : 'automated-library-media',
});
}
if (send.matrixTextEventId && !send.matrixTextEventId.startsWith('manual-') && send.matrixTextEventId !== 'unconfirmed') {
mediaByEventId.set(send.matrixTextEventId, { send, kind: 'library-reciprocal' });
}
}
return input.messages.map((message, index) => {
const eventId = message.eventId ?? syntheticEventId(message, index);
const existing = repository?.get(input.conversationId, eventId);
let actor: AttributedConversationMessage['actor'];
let kind: ConversationEventKind;
let confidence: AttributionConfidence;
let sourceReference: string | null = null;
if (!message.isSelf) {
actor = 'recipient';
kind = (message.media?.length ?? 0) > 0 ? 'inbound-media' : 'inbound-text';
confidence = message.eventId ? 'exact' : 'inferred';
} else if (existing?.actor === 'human' && existing.attributionConfidence === 'exact') {
actor = 'human';
kind = existing.eventKind;
confidence = 'exact';
sourceReference = existing.sourceReference;
} else if (message.eventId && input.opener?.matrixEventId === message.eventId) {
actor = 'ai';
kind = 'opener';
confidence = 'exact';
sourceReference = input.opener ? `sent-message:${input.opener.id}` : null;
} else if (message.eventId && claimsByEventId.has(message.eventId)) {
const claim = claimsByEventId.get(message.eventId)!;
actor = 'ai';
kind = 'automation-reply';
confidence = 'exact';
sourceReference = `claim:${claim.incomingKey}`;
} else if (message.eventId && mediaByEventId.has(message.eventId)) {
const matched = mediaByEventId.get(message.eventId)!;
actor = matched.send.initiatedBy === 'human' ? 'human' : 'ai';
kind = matched.kind;
confidence = 'exact';
sourceReference = `media-library-send:${matched.send.id}`;
} else if (isOpenerFingerprint(message, input.opener)) {
actor = 'ai';
kind = 'opener';
confidence = 'fingerprint';
sourceReference = input.opener ? `sent-message:${input.opener.id}` : null;
} else {
const claim = claimsByFingerprint.get(conversationMessageFingerprint(message.text));
if (claim) {
actor = 'ai';
kind = 'automation-reply';
confidence = 'fingerprint';
sourceReference = `claim:${claim.incomingKey}`;
} else {
actor = 'human';
kind = 'manual-text';
confidence = message.eventId ? 'inferred' : 'inferred';
}
}
repository?.upsert({
conversationId: input.conversationId,
eventId,
eventTimestamp: message.timestamp,
direction: message.isSelf ? 'outbound' : 'inbound',
actor,
eventKind: kind,
text: message.text,
sourceReference,
attributionConfidence: confidence,
observedAt,
});
return {
eventId,
timestamp: message.timestamp,
isSelf: message.isSelf,
text: message.text,
mediaCount: message.media?.length ?? 0,
actor,
kind,
confidence,
};
});
}
function isOpenerFingerprint(message: ChatMessage, opener: SentMessageRecord | undefined): boolean {
if (!opener || !message.isSelf) return false;
if (Math.abs(message.timestamp - opener.sentAt) > 60_000) return false;
return conversationMessageFingerprint(message.text) === conversationMessageFingerprint(opener.message);
}
function syntheticEventId(message: ChatMessage, index: number): string {
const digest = createHash('sha256')
.update(`${message.timestamp}\u0000${message.isSelf ? 'self' : 'recipient'}\u0000${message.text}\u0000${index}`)
.digest('hex')
.slice(0, 20);
return `legacy:${digest}`;
}