139 lines
5.5 KiB
TypeScript
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}`;
|
|
}
|