Add human-led conversation learning loop
This commit is contained in:
1 parent
bc42c55555
commit
687abab82b
52 files changed
+2112
-53
No files matched your search
@@ -0,0 +1,138 @@
|
||||
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}`;
|
||||
}
|
||||
Reference in new issue
Block a user