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 = { 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((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), }]; }); }