Files
RedditBot/src/services/conversation-learning-extractor.ts
T

135 lines
6.0 KiB
TypeScript

import type { LearningConfig } from '../config/index.js';
import type { AttributionConfidence } from '../database/conversation-event-repository.js';
import {
stableLearningId,
type ConversationLearningRepository,
type UpsertLearningDecision,
} from '../database/conversation-learning-repository.js';
import type { SentMessageRecord } from '../database/repositories.js';
import { containsMediaRequest } from './conversation-boundaries.js';
import type { ConversationState } from './conversation-state.js';
import type {
AttributedConversationMessage,
LearningCreditClass,
LearningStage,
LearningTrajectoryStatus,
LearningTurn,
} from './conversation-learning-types.js';
import { redactLearningText } from './conversation-learning-redactor.js';
const CONFIDENCE_RANK: Record<AttributionConfidence, number> = { inferred: 1, fingerprint: 2, exact: 3 };
export interface LearningExtractionInput {
conversationId: string;
messages: readonly AttributedConversationMessage[];
opener: SentMessageRecord;
state: ConversationState | undefined;
canonicalOutcome: 'engaged' | 'declined' | 'boundary' | 'upsell' | 'unknown' | undefined;
now?: number;
}
/** Derives revocable decision examples from an authoritative attributed timeline. */
export function extractConversationLearning(
input: LearningExtractionInput,
repository: ConversationLearningRepository,
config: LearningConfig,
): number {
if (!config.captureEnabled) return 0;
const now = input.now ?? Date.now();
const negative = input.canonicalOutcome && ['declined', 'boundary', 'upsell'].includes(input.canonicalOutcome);
const inboundImages = input.messages.filter((message) =>
message.actor === 'recipient' && message.kind === 'inbound-media' && message.mediaCount > 0);
let extracted = 0;
for (const image of inboundImages) {
const timeline = input.messages.filter((message) => message.timestamp <= image.timestamp);
const humans = timeline.filter((message) => message.actor === 'human');
const asks = timeline.filter((message) => message.isSelf && containsMediaRequest(message.text));
const directHumanAsk = [...humans].reverse().find((message) => containsMediaRequest(message.text));
const priorAiAsk = asks.find((message) => message.actor === 'ai');
const confidence = minimumConfidence(timeline);
const creditClass = classifyCredit(humans.length > 0, directHumanAsk !== undefined, priorAiAsk !== undefined, asks.length > 0);
const maturityAt = image.timestamp + config.maturityHours * 60 * 60 * 1_000;
const status: LearningTrajectoryStatus = negative
? 'invalidated'
: CONFIDENCE_RANK[confidence] < CONFIDENCE_RANK[config.minimumAttributionConfidence]
? 'needs-review'
: now < maturityAt ? 'provisional' : 'eligible';
const trajectoryId = stableLearningId(input.conversationId, image.eventId);
repository.upsertTrajectory({
id: trajectoryId,
conversationId: input.conversationId,
sentMessageId: input.opener.id,
subreddit: input.opener.subreddit ?? null,
sourceType: input.opener.sourceType ?? null,
policyMode: input.state?.automation.policyMode ?? null,
firstHumanEventId: humans[0]?.eventId ?? null,
outcomeEventId: image.eventId,
outcomeType: 'image-received',
creditClass,
attributionConfidence: confidence,
status,
maturityAt,
invalidatedAt: negative ? now : null,
invalidationReason: negative ? `Canonical outcome changed to ${input.canonicalOutcome}` : null,
now,
});
const decisionMessages = humans.filter((message) => message.kind === 'manual-text');
const decisions = decisionMessages.map((decision): UpsertLearningDecision => ({
id: stableLearningId(trajectoryId, decision.eventId),
trajectoryId,
decisionEventId: decision.eventId,
stage: inferStage(decision.text),
context: buildContext(timeline, decision, config.maxTurnsPerExample),
chosenResponse: redactLearningText(decision.text),
turnsUntilOutcome: timeline.filter((message) => message.timestamp > decision.timestamp).length,
isDirectMediaInvitation: containsMediaRequest(decision.text),
qualityStatus: status === 'provisional' ? 'eligible' : status,
now,
}));
repository.replaceDecisions(trajectoryId, decisions);
extracted += 1;
}
return extracted;
}
function classifyCredit(hasHuman: boolean, directHumanAsk: boolean, priorAiAsk: boolean, anyAsk: boolean): LearningCreditClass {
if (!hasHuman) return anyAsk ? 'autonomous-success' : 'recipient-initiated-media';
if (directHumanAsk) return priorAiAsk ? 'human-improved-success' : 'human-created-success';
return 'human-completed-ai-trajectory';
}
function minimumConfidence(messages: readonly AttributedConversationMessage[]): AttributionConfidence {
return messages.reduce<AttributionConfidence>((lowest, message) =>
CONFIDENCE_RANK[message.confidence] < CONFIDENCE_RANK[lowest] ? message.confidence : lowest, 'exact');
}
function inferStage(text: string): LearningStage {
if (containsMediaRequest(text)) return 'media-invitation';
if (/\b(sorry|understand|no pressure|comfortable|boundary)\b/i.test(text)) return 'handoff';
if (/\?/.test(text)) return 'answer';
return 'rapport';
}
function buildContext(
timeline: readonly AttributedConversationMessage[],
decision: AttributedConversationMessage,
maxTurns: number,
): LearningTurn[] {
return timeline
.filter((message) => message.timestamp < decision.timestamp && message.kind !== 'inbound-media')
.slice(-maxTurns)
.flatMap((message): LearningTurn[] => {
if (!['inbound-text', 'opener', 'automation-reply', 'manual-text', 'library-reciprocal'].includes(message.kind)) return [];
return [{
eventId: message.eventId,
timestamp: message.timestamp,
role: message.actor === 'recipient' ? 'recipient' : 'operator',
actor: message.actor === 'unknown' ? 'ai' : message.actor,
kind: message.kind as LearningTurn['kind'],
text: redactLearningText(message.text),
}];
});
}