135 lines
6.0 KiB
TypeScript
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),
|
|
}];
|
|
});
|
|
}
|