Add human-led conversation learning loop

This commit is contained in:
jjbrunton committed 2026-08-11 23:04:25 +01:00
1 parent bc42c55555
commit 687abab82b
52 files changed
+2112 -53

No files matched your search

+119
View File
@@ -653,6 +653,125 @@ export function runMigrations(db: Database.Database): void {
DROP TABLE account_browsing_settings;
ALTER TABLE account_browsing_settings_without_ceilings RENAME TO account_browsing_settings;
`),
() => {
addColumnIfNotExists(db, 'sent_messages', 'matrix_event_id', 'TEXT');
addColumnIfNotExists(db, 'sent_messages', 'image_outcome_credit', 'TEXT');
addColumnIfNotExists(db, 'sent_messages', 'image_outcome_at', 'INTEGER');
addColumnIfNotExists(db, 'conversation_automation_claims', 'matrix_event_id', 'TEXT');
addColumnIfNotExists(db, 'media_library_sends', 'initiated_by', "TEXT NOT NULL DEFAULT 'automation' CHECK(initiated_by IN ('automation', 'human'))");
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_sent_messages_matrix_event
ON sent_messages(account_id, matrix_event_id) WHERE matrix_event_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS conversation_events (
account_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
event_id TEXT NOT NULL,
event_timestamp INTEGER NOT NULL,
direction TEXT NOT NULL CHECK(direction IN ('inbound', 'outbound')),
actor TEXT NOT NULL CHECK(actor IN ('recipient', 'ai', 'human', 'system', 'unknown')),
event_kind TEXT NOT NULL CHECK(event_kind IN (
'inbound-text', 'inbound-media', 'opener', 'automation-reply',
'manual-text', 'automated-library-media', 'manual-library-media',
'library-reciprocal', 'unknown-outbound'
)),
text_sha256 TEXT,
source_reference TEXT,
attribution_confidence TEXT NOT NULL CHECK(attribution_confidence IN ('exact', 'fingerprint', 'inferred')),
observed_at INTEGER NOT NULL,
PRIMARY KEY (account_id, conversation_id, event_id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_conversation_events_timeline
ON conversation_events(account_id, conversation_id, event_timestamp, event_id);
CREATE INDEX IF NOT EXISTS idx_conversation_events_actor
ON conversation_events(account_id, actor, event_kind, event_timestamp DESC);
CREATE TABLE IF NOT EXISTS conversation_learning_trajectories (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
sent_message_id INTEGER,
subreddit TEXT,
source_type TEXT CHECK(source_type IN ('image', 'chat') OR source_type IS NULL),
policy_mode TEXT CHECK(policy_mode IN ('legacy', 'r4r', 'r4r-media', 'manual') OR policy_mode IS NULL),
first_human_event_id TEXT,
outcome_event_id TEXT NOT NULL,
outcome_type TEXT NOT NULL CHECK(outcome_type IN ('image-received', 'reciprocal-engagement')),
credit_class TEXT NOT NULL CHECK(credit_class IN (
'human-created-success', 'human-improved-success', 'human-completed-ai-trajectory',
'autonomous-success', 'recipient-initiated-media', 'uncertain'
)),
attribution_confidence TEXT NOT NULL CHECK(attribution_confidence IN ('exact', 'fingerprint', 'inferred')),
status TEXT NOT NULL CHECK(status IN ('provisional', 'eligible', 'excluded', 'invalidated', 'needs-review')),
maturity_at INTEGER NOT NULL,
invalidated_at INTEGER,
invalidation_reason TEXT,
reviewed_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(account_id, conversation_id, outcome_event_id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE,
FOREIGN KEY (account_id, sent_message_id) REFERENCES sent_messages(account_id, id)
);
CREATE INDEX IF NOT EXISTS idx_learning_trajectories_status
ON conversation_learning_trajectories(status, credit_class, maturity_at, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_learning_trajectories_account
ON conversation_learning_trajectories(account_id, conversation_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS conversation_learning_decisions (
id TEXT PRIMARY KEY,
trajectory_id TEXT NOT NULL,
account_id TEXT NOT NULL,
decision_event_id TEXT NOT NULL,
stage TEXT NOT NULL CHECK(stage IN ('rapport', 'answer', 'flirt', 'disclosure', 'media-invitation', 'handoff', 'unknown')),
context_json TEXT NOT NULL,
chosen_response TEXT NOT NULL,
turns_until_outcome INTEGER NOT NULL,
is_direct_media_invitation INTEGER NOT NULL CHECK(is_direct_media_invitation IN (0, 1)),
quality_status TEXT NOT NULL CHECK(quality_status IN ('eligible', 'excluded', 'invalidated', 'needs-review')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(trajectory_id, decision_event_id),
FOREIGN KEY (trajectory_id) REFERENCES conversation_learning_trajectories(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_learning_decisions_retrieval
ON conversation_learning_decisions(account_id, quality_status, stage, updated_at DESC);
CREATE TABLE IF NOT EXISTS learning_generation_context (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
incoming_event_id TEXT NOT NULL,
purpose TEXT NOT NULL CHECK(purpose IN ('reply', 'media-ask')),
mode TEXT NOT NULL CHECK(mode IN ('shadow', 'injected')),
policy_version TEXT NOT NULL,
example_ids_json TEXT NOT NULL,
prompt_sha256 TEXT,
generated_at INTEGER NOT NULL,
UNIQUE(account_id, conversation_id, incoming_event_id, purpose, policy_version, mode),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_learning_generation_recent
ON learning_generation_context(account_id, generated_at DESC);
CREATE TABLE IF NOT EXISTS learning_evaluation_runs (
id TEXT PRIMARY KEY,
policy_version TEXT NOT NULL,
dataset_version TEXT NOT NULL,
baseline_policy_version TEXT,
status TEXT NOT NULL CHECK(status IN ('running', 'passed', 'failed')),
total_cases INTEGER NOT NULL DEFAULT 0,
hard_gate_failures INTEGER NOT NULL DEFAULT 0,
summary_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
completed_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_learning_evaluations_recent
ON learning_evaluation_runs(created_at DESC);
`);
},
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
@@ -0,0 +1,162 @@
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
export const CONVERSATION_EVENT_ACTORS = ['recipient', 'ai', 'human', 'system', 'unknown'] as const;
export type ConversationEventActor = (typeof CONVERSATION_EVENT_ACTORS)[number];
export const CONVERSATION_EVENT_KINDS = [
'inbound-text',
'inbound-media',
'opener',
'automation-reply',
'manual-text',
'automated-library-media',
'manual-library-media',
'library-reciprocal',
'unknown-outbound',
] as const;
export type ConversationEventKind = (typeof CONVERSATION_EVENT_KINDS)[number];
export type AttributionConfidence = 'exact' | 'fingerprint' | 'inferred';
export interface ConversationEventRecord {
accountId: string;
conversationId: string;
eventId: string;
eventTimestamp: number;
direction: 'inbound' | 'outbound';
actor: ConversationEventActor;
eventKind: ConversationEventKind;
textSha256: string | null;
sourceReference: string | null;
attributionConfidence: AttributionConfidence;
observedAt: number;
}
export interface UpsertConversationEvent {
conversationId: string;
eventId: string;
eventTimestamp: number;
direction: 'inbound' | 'outbound';
actor: ConversationEventActor;
eventKind: ConversationEventKind;
text?: string | null;
sourceReference?: string | null;
attributionConfidence: AttributionConfidence;
observedAt?: number;
}
/** Account-scoped, text-minimising provenance for Matrix conversation events. */
export class ConversationEventRepository {
constructor(
private readonly db: Database.Database,
private readonly accountId: string,
) {}
upsert(input: UpsertConversationEvent): ConversationEventRecord {
const observedAt = input.observedAt ?? Date.now();
const textSha256 = input.text ? conversationTextSha256(input.text) : null;
this.db.prepare(`
INSERT INTO conversation_events (
account_id, conversation_id, event_id, event_timestamp, direction,
actor, event_kind, text_sha256, source_reference,
attribution_confidence, observed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id, conversation_id, event_id) DO UPDATE SET
event_timestamp = excluded.event_timestamp,
direction = excluded.direction,
actor = CASE
WHEN conversation_events.attribution_confidence = 'exact' THEN conversation_events.actor
WHEN excluded.attribution_confidence = 'exact' THEN excluded.actor
WHEN conversation_events.attribution_confidence = 'fingerprint' THEN conversation_events.actor
ELSE excluded.actor
END,
event_kind = CASE
WHEN conversation_events.attribution_confidence = 'exact' THEN conversation_events.event_kind
WHEN excluded.attribution_confidence = 'exact' THEN excluded.event_kind
WHEN conversation_events.attribution_confidence = 'fingerprint' THEN conversation_events.event_kind
ELSE excluded.event_kind
END,
text_sha256 = COALESCE(excluded.text_sha256, conversation_events.text_sha256),
source_reference = COALESCE(excluded.source_reference, conversation_events.source_reference),
attribution_confidence = CASE
WHEN conversation_events.attribution_confidence = 'exact' OR excluded.attribution_confidence = 'exact' THEN 'exact'
WHEN conversation_events.attribution_confidence = 'fingerprint' OR excluded.attribution_confidence = 'fingerprint' THEN 'fingerprint'
ELSE 'inferred'
END,
observed_at = MAX(conversation_events.observed_at, excluded.observed_at)
`).run(
this.accountId,
input.conversationId,
input.eventId,
input.eventTimestamp,
input.direction,
input.actor,
input.eventKind,
textSha256,
input.sourceReference ?? null,
input.attributionConfidence,
observedAt,
);
return this.get(input.conversationId, input.eventId)!;
}
get(conversationId: string, eventId: string): ConversationEventRecord | undefined {
const row = this.db.prepare(`
SELECT * FROM conversation_events
WHERE account_id = ? AND conversation_id = ? AND event_id = ?
`).get(this.accountId, conversationId, eventId) as EventRow | undefined;
return row ? mapRow(row) : undefined;
}
listConversation(conversationId: string): ConversationEventRecord[] {
return (this.db.prepare(`
SELECT * FROM conversation_events
WHERE account_id = ? AND conversation_id = ?
ORDER BY event_timestamp ASC, event_id ASC
`).all(this.accountId, conversationId) as EventRow[]).map(mapRow);
}
countByConfidence(): Record<AttributionConfidence, number> {
const result: Record<AttributionConfidence, number> = { exact: 0, fingerprint: 0, inferred: 0 };
const rows = this.db.prepare(`
SELECT attribution_confidence, COUNT(*) AS total
FROM conversation_events WHERE account_id = ? GROUP BY attribution_confidence
`).all(this.accountId) as Array<{ attribution_confidence: AttributionConfidence; total: number }>;
for (const row of rows) result[row.attribution_confidence] = row.total;
return result;
}
}
interface EventRow {
account_id: string;
conversation_id: string;
event_id: string;
event_timestamp: number;
direction: 'inbound' | 'outbound';
actor: ConversationEventActor;
event_kind: ConversationEventKind;
text_sha256: string | null;
source_reference: string | null;
attribution_confidence: AttributionConfidence;
observed_at: number;
}
function mapRow(row: EventRow): ConversationEventRecord {
return {
accountId: row.account_id,
conversationId: row.conversation_id,
eventId: row.event_id,
eventTimestamp: row.event_timestamp,
direction: row.direction,
actor: row.actor,
eventKind: row.event_kind,
textSha256: row.text_sha256,
sourceReference: row.source_reference,
attributionConfidence: row.attribution_confidence,
observedAt: row.observed_at,
};
}
export function conversationTextSha256(text: string): string {
return createHash('sha256').update(text.trim()).digest('hex');
}
@@ -0,0 +1,549 @@
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import type { AttributionConfidence } from './conversation-event-repository.js';
import {
LearningTurnSchema,
type LearningCreditClass,
type LearningDecisionRecord,
type LearningDecisionStatus,
type LearningStage,
type LearningTrajectoryDetail,
type LearningTrajectoryRecord,
type LearningTrajectoryStatus,
type LearningTurn,
type RetrievedLearningExample,
} from '../services/conversation-learning-types.js';
import type { ConversationPolicyMode } from '../services/conversation-state.js';
export interface UpsertLearningTrajectory {
id: string;
conversationId: string;
sentMessageId: number | null;
subreddit: string | null;
sourceType: 'image' | 'chat' | null;
policyMode: ConversationPolicyMode | null;
firstHumanEventId: string | null;
outcomeEventId: string;
outcomeType: 'image-received' | 'reciprocal-engagement';
creditClass: LearningCreditClass;
attributionConfidence: AttributionConfidence;
status: LearningTrajectoryStatus;
maturityAt: number;
invalidatedAt?: number | null;
invalidationReason?: string | null;
now?: number;
}
export interface UpsertLearningDecision {
id: string;
trajectoryId: string;
decisionEventId: string;
stage: LearningStage;
context: LearningTurn[];
chosenResponse: string;
turnsUntilOutcome: number;
isDirectMediaInvitation: boolean;
qualityStatus: LearningDecisionStatus;
now?: number;
}
export interface LearningSummary {
total: number;
provisional: number;
eligible: number;
excluded: number;
invalidated: number;
needsReview: number;
humanCreated: number;
humanImproved: number;
humanCompletedAi: number;
autonomous: number;
recipientInitiated: number;
uncertain: number;
exact: number;
fingerprint: number;
inferred: number;
}
/** Persistence for derived, revocable learning trajectories and decisions. */
export class ConversationLearningRepository {
constructor(
private readonly db: Database.Database,
private readonly accountId?: string,
) {}
upsertTrajectory(input: UpsertLearningTrajectory): LearningTrajectoryRecord {
const accountId = this.requireAccountId();
const now = input.now ?? Date.now();
this.db.prepare(`
INSERT INTO conversation_learning_trajectories (
id, account_id, conversation_id, sent_message_id, subreddit, source_type,
policy_mode, first_human_event_id, outcome_event_id, outcome_type,
credit_class, attribution_confidence, status, maturity_at,
invalidated_at, invalidation_reason, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id, conversation_id, outcome_event_id) DO UPDATE SET
sent_message_id = excluded.sent_message_id,
subreddit = excluded.subreddit,
source_type = excluded.source_type,
policy_mode = excluded.policy_mode,
first_human_event_id = excluded.first_human_event_id,
credit_class = excluded.credit_class,
attribution_confidence = excluded.attribution_confidence,
status = CASE
WHEN conversation_learning_trajectories.status = 'excluded' AND excluded.status != 'invalidated'
THEN 'excluded'
WHEN conversation_learning_trajectories.reviewed_at IS NOT NULL
AND conversation_learning_trajectories.status = 'eligible'
AND excluded.status != 'invalidated'
THEN 'eligible'
ELSE excluded.status
END,
maturity_at = excluded.maturity_at,
invalidated_at = excluded.invalidated_at,
invalidation_reason = excluded.invalidation_reason,
updated_at = excluded.updated_at
`).run(
input.id, accountId, input.conversationId, input.sentMessageId,
input.subreddit, input.sourceType, input.policyMode, input.firstHumanEventId,
input.outcomeEventId, input.outcomeType, input.creditClass,
input.attributionConfidence, input.status, input.maturityAt,
input.invalidatedAt ?? null, input.invalidationReason ?? null, now, now,
);
if (input.sentMessageId !== null && input.outcomeType === 'image-received') {
this.db.prepare(`
UPDATE sent_messages
SET image_outcome_credit = ?, image_outcome_at = ?
WHERE account_id = ? AND id = ?
`).run(input.creditClass, now, accountId, input.sentMessageId);
}
return this.getTrajectory(input.id)!;
}
replaceDecisions(trajectoryId: string, decisions: readonly UpsertLearningDecision[]): void {
const accountId = this.requireAccountId();
this.db.transaction(() => {
const keep = new Set(decisions.map((decision) => decision.id));
const existing = this.db.prepare(`
SELECT id FROM conversation_learning_decisions
WHERE account_id = ? AND trajectory_id = ?
`).all(accountId, trajectoryId) as Array<{ id: string }>;
for (const row of existing) {
if (!keep.has(row.id)) {
this.db.prepare('DELETE FROM conversation_learning_decisions WHERE account_id = ? AND id = ?')
.run(accountId, row.id);
}
}
for (const decision of decisions) this.upsertDecision(decision);
})();
}
upsertDecision(input: UpsertLearningDecision): LearningDecisionRecord {
const accountId = this.requireAccountId();
const now = input.now ?? Date.now();
this.db.prepare(`
INSERT INTO conversation_learning_decisions (
id, trajectory_id, account_id, decision_event_id, stage, context_json,
chosen_response, turns_until_outcome, is_direct_media_invitation,
quality_status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(trajectory_id, decision_event_id) DO UPDATE SET
stage = excluded.stage,
context_json = excluded.context_json,
chosen_response = excluded.chosen_response,
turns_until_outcome = excluded.turns_until_outcome,
is_direct_media_invitation = excluded.is_direct_media_invitation,
quality_status = CASE
WHEN conversation_learning_decisions.quality_status = 'excluded' AND excluded.quality_status != 'invalidated'
THEN 'excluded'
WHEN EXISTS (
SELECT 1 FROM conversation_learning_trajectories t
WHERE t.id = excluded.trajectory_id AND t.reviewed_at IS NOT NULL AND t.status = 'eligible'
) AND excluded.quality_status != 'invalidated'
THEN 'eligible'
ELSE excluded.quality_status
END,
updated_at = excluded.updated_at
`).run(
input.id, input.trajectoryId, accountId, input.decisionEventId, input.stage,
JSON.stringify(input.context), input.chosenResponse, input.turnsUntilOutcome,
input.isDirectMediaInvitation ? 1 : 0, input.qualityStatus, now, now,
);
return this.getDecision(input.id)!;
}
getTrajectory(id: string): LearningTrajectoryDetail | undefined {
const where = this.accountId ? 'id = ? AND account_id = ?' : 'id = ?';
const params = this.accountId ? [id, this.accountId] : [id];
const row = this.db.prepare(`SELECT * FROM conversation_learning_trajectories WHERE ${where}`)
.get(...params) as TrajectoryRow | undefined;
if (!row) return undefined;
const decisions = (this.db.prepare(`
SELECT * FROM conversation_learning_decisions
WHERE trajectory_id = ? ORDER BY created_at ASC, id ASC
`).all(id) as DecisionRow[]).map(mapDecision);
return { ...mapTrajectory(row), decisions };
}
getDecision(id: string): LearningDecisionRecord | undefined {
const where = this.accountId ? 'id = ? AND account_id = ?' : 'id = ?';
const params = this.accountId ? [id, this.accountId] : [id];
const row = this.db.prepare(`SELECT * FROM conversation_learning_decisions WHERE ${where}`)
.get(...params) as DecisionRow | undefined;
return row ? mapDecision(row) : undefined;
}
listTrajectories(input: { status?: LearningTrajectoryStatus; limit?: number } = {}): LearningTrajectoryRecord[] {
const clauses: string[] = [];
const params: Array<string | number> = [];
if (this.accountId) {
clauses.push('account_id = ?');
params.push(this.accountId);
}
if (input.status) {
clauses.push('status = ?');
params.push(input.status);
}
params.push(Math.min(500, Math.max(1, input.limit ?? 100)));
return (this.db.prepare(`
SELECT * FROM conversation_learning_trajectories
${clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''}
ORDER BY updated_at DESC, id DESC LIMIT ?
`).all(...params) as TrajectoryRow[]).map(mapTrajectory);
}
promoteMature(now = Date.now()): number {
const accountClause = this.accountId ? 'AND account_id = ?' : '';
const params = this.accountId ? [now, now, this.accountId] : [now, now];
return this.db.prepare(`
UPDATE conversation_learning_trajectories
SET status = 'eligible', updated_at = ?
WHERE status = 'provisional' AND maturity_at <= ? ${accountClause}
`).run(...params).changes;
}
setReviewStatus(id: string, status: 'eligible' | 'excluded', now = Date.now()): boolean {
const accountClause = this.accountId ? 'AND account_id = ?' : '';
const params = this.accountId ? [status, now, now, id, this.accountId] : [status, now, now, id];
const changed = this.db.prepare(`
UPDATE conversation_learning_trajectories
SET status = ?, reviewed_at = ?, updated_at = ?, invalidated_at = NULL, invalidation_reason = NULL
WHERE id = ? ${accountClause} AND status != 'invalidated'
`).run(...params).changes > 0;
if (changed) {
this.db.prepare(`
UPDATE conversation_learning_decisions
SET quality_status = ?, updated_at = ? WHERE trajectory_id = ?
`).run(status, now, id);
}
return changed;
}
invalidateBySentMessageId(sentMessageId: number, reason: string, now = Date.now()): number {
const accountId = this.requireAccountId();
const rows = this.db.prepare(`
SELECT id FROM conversation_learning_trajectories
WHERE account_id = ? AND sent_message_id = ? AND status != 'invalidated'
`).all(accountId, sentMessageId) as Array<{ id: string }>;
if (rows.length === 0) return 0;
this.db.transaction(() => {
this.db.prepare(`
UPDATE conversation_learning_trajectories
SET status = 'invalidated', invalidated_at = ?, invalidation_reason = ?, updated_at = ?
WHERE account_id = ? AND sent_message_id = ?
`).run(now, reason.slice(0, 500), now, accountId, sentMessageId);
for (const row of rows) {
this.db.prepare(`
UPDATE conversation_learning_decisions
SET quality_status = 'invalidated', updated_at = ? WHERE trajectory_id = ?
`).run(now, row.id);
}
})();
return rows.length;
}
getEligibleExamples(input: {
conversationId: string;
policyMode: ConversationPolicyMode;
stage: LearningStage;
subreddit: string | null;
minimumConfidence: AttributionConfidence;
humanLimit: number;
autonomousLimit: number;
totalLimit: number;
}): RetrievedLearningExample[] {
const accountId = this.requireAccountId();
const confidenceRank: Record<AttributionConfidence, number> = { inferred: 1, fingerprint: 2, exact: 3 };
const minimumRank = confidenceRank[input.minimumConfidence];
const rows = this.db.prepare(`
SELECT d.*, t.credit_class, t.subreddit, t.policy_mode, t.attribution_confidence
FROM conversation_learning_decisions d
JOIN conversation_learning_trajectories t ON t.id = d.trajectory_id
WHERE d.account_id = ? AND d.quality_status = 'eligible' AND t.status = 'eligible'
AND t.conversation_id != ?
AND (t.policy_mode = ? OR t.policy_mode IS NULL)
AND (d.stage = ? OR d.stage = 'unknown')
ORDER BY
CASE WHEN t.subreddit = ? THEN 0 ELSE 1 END,
CASE t.attribution_confidence WHEN 'exact' THEN 0 WHEN 'fingerprint' THEN 1 ELSE 2 END,
t.updated_at DESC,
d.updated_at DESC
LIMIT 200
`).all(accountId, input.conversationId, input.policyMode, input.stage, input.subreddit) as RetrievalRow[];
const eligible = rows.filter((row) => confidenceRank[row.attribution_confidence] >= minimumRank);
const human = eligible.filter((row) => row.credit_class.startsWith('human-')).slice(0, input.humanLimit);
const autonomous = eligible.filter((row) =>
row.credit_class === 'autonomous-success' || row.credit_class === 'recipient-initiated-media')
.slice(0, input.autonomousLimit);
return [...human, ...autonomous].slice(0, input.totalLimit).map(mapRetrieval);
}
recordGeneration(input: {
conversationId: string;
incomingEventId: string;
purpose: 'reply' | 'media-ask';
mode: 'shadow' | 'injected';
policyVersion: string;
exampleIds: readonly string[];
promptSha256?: string | null;
generatedAt?: number;
}): void {
const accountId = this.requireAccountId();
this.db.prepare(`
INSERT INTO learning_generation_context (
account_id, conversation_id, incoming_event_id, purpose, mode,
policy_version, example_ids_json, prompt_sha256, generated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id, conversation_id, incoming_event_id, purpose, policy_version, mode)
DO UPDATE SET example_ids_json = excluded.example_ids_json,
prompt_sha256 = excluded.prompt_sha256, generated_at = excluded.generated_at
`).run(
accountId, input.conversationId, input.incomingEventId, input.purpose, input.mode,
input.policyVersion, JSON.stringify(input.exampleIds), input.promptSha256 ?? null,
input.generatedAt ?? Date.now(),
);
}
getSummary(): LearningSummary {
const accountClause = this.accountId ? 'WHERE account_id = ?' : '';
const params = this.accountId ? [this.accountId] : [];
const row = this.db.prepare(`
SELECT COUNT(*) AS total,
SUM(status = 'provisional') AS provisional,
SUM(status = 'eligible') AS eligible,
SUM(status = 'excluded') AS excluded,
SUM(status = 'invalidated') AS invalidated,
SUM(status = 'needs-review') AS needs_review,
SUM(credit_class = 'human-created-success') AS human_created,
SUM(credit_class = 'human-improved-success') AS human_improved,
SUM(credit_class = 'human-completed-ai-trajectory') AS human_completed_ai,
SUM(credit_class = 'autonomous-success') AS autonomous,
SUM(credit_class = 'recipient-initiated-media') AS recipient_initiated,
SUM(credit_class = 'uncertain') AS uncertain,
SUM(attribution_confidence = 'exact') AS exact,
SUM(attribution_confidence = 'fingerprint') AS fingerprint,
SUM(attribution_confidence = 'inferred') AS inferred
FROM conversation_learning_trajectories ${accountClause}
`).get(...params) as Record<string, number | null>;
return {
total: row.total ?? 0,
provisional: row.provisional ?? 0,
eligible: row.eligible ?? 0,
excluded: row.excluded ?? 0,
invalidated: row.invalidated ?? 0,
needsReview: row.needs_review ?? 0,
humanCreated: row.human_created ?? 0,
humanImproved: row.human_improved ?? 0,
humanCompletedAi: row.human_completed_ai ?? 0,
autonomous: row.autonomous ?? 0,
recipientInitiated: row.recipient_initiated ?? 0,
uncertain: row.uncertain ?? 0,
exact: row.exact ?? 0,
fingerprint: row.fingerprint ?? 0,
inferred: row.inferred ?? 0,
};
}
listEvaluationRuns(limit = 20): LearningEvaluationRun[] {
return (this.db.prepare(`
SELECT * FROM learning_evaluation_runs ORDER BY created_at DESC LIMIT ?
`).all(Math.min(100, Math.max(1, limit))) as EvaluationRow[]).map((row) => ({
id: row.id,
policyVersion: row.policy_version,
datasetVersion: row.dataset_version,
baselinePolicyVersion: row.baseline_policy_version,
status: row.status,
totalCases: row.total_cases,
hardGateFailures: row.hard_gate_failures,
summary: parseObject(row.summary_json),
createdAt: row.created_at,
completedAt: row.completed_at,
}));
}
saveEvaluationRun(run: LearningEvaluationRun): void {
this.db.prepare(`
INSERT INTO learning_evaluation_runs (
id, policy_version, dataset_version, baseline_policy_version, status,
total_cases, hard_gate_failures, summary_json, created_at, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET status = excluded.status,
total_cases = excluded.total_cases,
hard_gate_failures = excluded.hard_gate_failures,
summary_json = excluded.summary_json,
completed_at = excluded.completed_at
`).run(
run.id, run.policyVersion, run.datasetVersion, run.baselinePolicyVersion,
run.status, run.totalCases, run.hardGateFailures, JSON.stringify(run.summary),
run.createdAt, run.completedAt,
);
}
private requireAccountId(): string {
if (!this.accountId) throw new Error('An account ID is required for this learning operation');
return this.accountId;
}
}
export interface LearningEvaluationRun {
id: string;
policyVersion: string;
datasetVersion: string;
baselinePolicyVersion: string | null;
status: 'running' | 'passed' | 'failed';
totalCases: number;
hardGateFailures: number;
summary: Record<string, unknown>;
createdAt: number;
completedAt: number | null;
}
interface TrajectoryRow {
id: string;
account_id: string;
conversation_id: string;
sent_message_id: number | null;
subreddit: string | null;
source_type: 'image' | 'chat' | null;
policy_mode: ConversationPolicyMode | null;
first_human_event_id: string | null;
outcome_event_id: string;
outcome_type: 'image-received' | 'reciprocal-engagement';
credit_class: LearningCreditClass;
attribution_confidence: AttributionConfidence;
status: LearningTrajectoryStatus;
maturity_at: number;
invalidated_at: number | null;
invalidation_reason: string | null;
reviewed_at: number | null;
created_at: number;
updated_at: number;
}
interface DecisionRow {
id: string;
trajectory_id: string;
account_id: string;
decision_event_id: string;
stage: LearningStage;
context_json: string;
chosen_response: string;
turns_until_outcome: number;
is_direct_media_invitation: number;
quality_status: LearningDecisionStatus;
created_at: number;
updated_at: number;
}
interface RetrievalRow extends DecisionRow {
credit_class: LearningCreditClass;
subreddit: string | null;
policy_mode: ConversationPolicyMode | null;
attribution_confidence: AttributionConfidence;
}
interface EvaluationRow {
id: string;
policy_version: string;
dataset_version: string;
baseline_policy_version: string | null;
status: 'running' | 'passed' | 'failed';
total_cases: number;
hard_gate_failures: number;
summary_json: string;
created_at: number;
completed_at: number | null;
}
function mapTrajectory(row: TrajectoryRow): LearningTrajectoryRecord {
return {
id: row.id,
accountId: row.account_id,
conversationId: row.conversation_id,
sentMessageId: row.sent_message_id,
subreddit: row.subreddit,
sourceType: row.source_type,
policyMode: row.policy_mode,
firstHumanEventId: row.first_human_event_id,
outcomeEventId: row.outcome_event_id,
outcomeType: row.outcome_type,
creditClass: row.credit_class,
attributionConfidence: row.attribution_confidence,
status: row.status,
maturityAt: row.maturity_at,
invalidatedAt: row.invalidated_at,
invalidationReason: row.invalidation_reason,
reviewedAt: row.reviewed_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapDecision(row: DecisionRow): LearningDecisionRecord {
const parsed = LearningTurnSchema.array().safeParse(parseJson(row.context_json));
return {
id: row.id,
trajectoryId: row.trajectory_id,
accountId: row.account_id,
decisionEventId: row.decision_event_id,
stage: row.stage,
context: parsed.success ? parsed.data : [],
chosenResponse: row.chosen_response,
turnsUntilOutcome: row.turns_until_outcome,
isDirectMediaInvitation: Boolean(row.is_direct_media_invitation),
qualityStatus: row.quality_status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapRetrieval(row: RetrievalRow): RetrievedLearningExample {
const decision = mapDecision(row);
return {
decisionId: decision.id,
trajectoryId: decision.trajectoryId,
creditClass: row.credit_class,
stage: decision.stage,
context: decision.context,
chosenResponse: decision.chosenResponse,
turnsUntilOutcome: decision.turnsUntilOutcome,
subreddit: row.subreddit,
policyMode: row.policy_mode,
};
}
function parseJson(value: string): unknown {
try { return JSON.parse(value) as unknown; }
catch { return null; }
}
function parseObject(value: string): Record<string, unknown> {
const parsed = parseJson(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
}
export function stableLearningId(...parts: readonly string[]): string {
return createHash('sha256').update(parts.join('\u0000')).digest('hex').slice(0, 32);
}
+6 -3
View File
@@ -43,6 +43,7 @@ interface SendRow {
matrix_text_event_id: string | null;
mxc_uri: string | null;
error: string | null;
initiated_by: 'automation' | 'human';
}
export interface CreateMediaLibraryAsset {
@@ -164,18 +165,19 @@ export class MediaLibraryRepository {
conversationId: string,
incomingKey: string,
requestKind: MediaRequestKind,
initiatedBy: 'automation' | 'human' = 'automation',
now = Date.now(),
): MediaLibrarySend | null {
try {
this.db.prepare(`
INSERT INTO media_library_sends (
asset_id, asset_sha256, account_id, conversation_id, incoming_key, request_kind,
status, requested_at
status, requested_at, initiated_by
)
SELECT asset_id, sha256, ?, ?, ?, ?, 'pending', ?
SELECT asset_id, sha256, ?, ?, ?, ?, 'pending', ?, ?
FROM media_library_assets
WHERE asset_id = ? AND deleted_at IS NULL AND enabled = 1
`).run(accountId, conversationId, incomingKey, requestKind, now, assetId);
`).run(accountId, conversationId, incomingKey, requestKind, now, initiatedBy, assetId);
return this.getSend(accountId, conversationId, incomingKey) ?? null;
} catch (error) {
if (error instanceof Error && /UNIQUE constraint failed/.test(error.message)) return null;
@@ -253,5 +255,6 @@ function mapSend(row: SendRow): MediaLibrarySend {
matrixTextEventId: row.matrix_text_event_id,
mxcUri: row.mxc_uri,
error: row.error,
initiatedBy: row.initiated_by,
};
}
+52 -9
View File
@@ -39,6 +39,9 @@ export interface SentMessageRecord {
modelId?: string | null;
selectionReason?: 'explore' | 'exploit' | null;
sourceType?: 'image' | 'chat' | null;
matrixEventId?: string | null;
imageOutcomeCredit?: string | null;
imageOutcomeAt?: number | null;
subreddit?: string;
postTitle?: string;
postUrl?: string;
@@ -597,13 +600,13 @@ export class MessagesRepository {
this.insertStmt = db.prepare(`
INSERT INTO sent_messages (
account_id, post_id, recipient, subject, message, sent_at, strategy_id, chat_room_id,
experiment_id, prompt_version, model_id, selection_reason, source_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
experiment_id, prompt_version, model_id, selection_reason, source_type, matrix_event_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
this.insertChatStmt = db.prepare(`
INSERT INTO sent_messages (account_id, post_id, recipient, subject, message, sent_at, chat_room_id)
VALUES (?, NULL, ?, ?, ?, ?, ?)
INSERT INTO sent_messages (account_id, post_id, recipient, subject, message, sent_at, chat_room_id, matrix_event_id)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?)
`);
this.getByChatRoomIdStmt = db.prepare(`
@@ -679,6 +682,7 @@ export class MessagesRepository {
modelId?: string | null;
selectionReason?: 'explore' | 'exploit' | null;
sourceType?: 'image' | 'chat' | null;
matrixEventId?: string | null;
}): number {
const result = this.insertStmt.run(
this.accountId,
@@ -694,6 +698,7 @@ export class MessagesRepository {
message.modelId ?? null,
message.selectionReason ?? null,
message.sourceType ?? null,
message.matrixEventId ?? null,
);
return result.lastInsertRowid as number;
}
@@ -704,6 +709,7 @@ export class MessagesRepository {
message: string;
sentAt: number;
chatRoomId: string;
matrixEventId?: string | null;
}): number {
const result = this.insertChatStmt.run(
this.accountId,
@@ -712,6 +718,7 @@ export class MessagesRepository {
message.message,
message.sentAt,
message.chatRoomId,
message.matrixEventId ?? null,
);
return result.lastInsertRowid as number;
}
@@ -956,6 +963,9 @@ export class MessagesRepository {
modelId: (row.model_id as string | null) ?? null,
selectionReason: (row.selection_reason as 'explore' | 'exploit' | null) ?? null,
sourceType: (row.source_type as 'image' | 'chat' | null) ?? null,
matrixEventId: (row.matrix_event_id as string | null) ?? null,
imageOutcomeCredit: (row.image_outcome_credit as string | null) ?? null,
imageOutcomeAt: (row.image_outcome_at as number | null) ?? null,
subreddit: (row.subreddit as string) || undefined,
postTitle: (row.post_title as string) || undefined,
postUrl: (row.post_url as string) || undefined,
@@ -1313,6 +1323,7 @@ export interface ConversationAutomationClaimRecord {
attemptCount: number;
sentAt: number | null;
supersededAt: number | null;
matrixEventId: string | null;
}
export class ConversationAutomationClaimRepository {
@@ -1341,7 +1352,7 @@ export class ConversationAutomationClaimRepository {
getClaim(conversationId: string, incomingKey: string): ConversationAutomationClaimRecord | undefined {
const row = this.db.prepare(`
SELECT conversation_id, incoming_key, status, reply_text, incoming_at,
claimed_at, last_attempt_at, attempt_count, sent_at, superseded_at
claimed_at, last_attempt_at, attempt_count, sent_at, superseded_at, matrix_event_id
FROM conversation_automation_claims
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ?
`).get(this.accountId, conversationId, incomingKey) as Record<string, unknown> | undefined;
@@ -1357,13 +1368,14 @@ export class ConversationAutomationClaimRepository {
attemptCount: row.attempt_count as number,
sentAt: (row.sent_at as number | null) ?? null,
supersededAt: (row.superseded_at as number | null) ?? null,
matrixEventId: (row.matrix_event_id as string | null) ?? null,
};
}
listPending(): ConversationAutomationClaimRecord[] {
const rows = this.db.prepare(`
SELECT conversation_id, incoming_key, status, reply_text, incoming_at,
claimed_at, last_attempt_at, attempt_count, sent_at, superseded_at
claimed_at, last_attempt_at, attempt_count, sent_at, superseded_at, matrix_event_id
FROM conversation_automation_claims
WHERE account_id = ? AND status = 'pending'
ORDER BY claimed_at ASC
@@ -1379,6 +1391,30 @@ export class ConversationAutomationClaimRepository {
attemptCount: row.attempt_count as number,
sentAt: (row.sent_at as number | null) ?? null,
supersededAt: (row.superseded_at as number | null) ?? null,
matrixEventId: (row.matrix_event_id as string | null) ?? null,
}));
}
listConversation(conversationId: string): ConversationAutomationClaimRecord[] {
const rows = this.db.prepare(`
SELECT conversation_id, incoming_key, status, reply_text, incoming_at,
claimed_at, last_attempt_at, attempt_count, sent_at, superseded_at, matrix_event_id
FROM conversation_automation_claims
WHERE account_id = ? AND conversation_id = ?
ORDER BY claimed_at ASC
`).all(this.accountId, conversationId) as Array<Record<string, unknown>>;
return rows.map((row) => ({
conversationId: row.conversation_id as string,
incomingKey: row.incoming_key as string,
status: row.status as ConversationAutomationClaimRecord['status'],
replyText: (row.reply_text as string | null) ?? null,
incomingAt: (row.incoming_at as number | null) ?? null,
claimedAt: row.claimed_at as number,
lastAttemptAt: (row.last_attempt_at as number | null) ?? null,
attemptCount: row.attempt_count as number,
sentAt: (row.sent_at as number | null) ?? null,
supersededAt: (row.superseded_at as number | null) ?? null,
matrixEventId: (row.matrix_event_id as string | null) ?? null,
}));
}
@@ -1397,12 +1433,19 @@ export class ConversationAutomationClaimRepository {
`).run(this.accountId, conversationId, incomingKey).changes === 1;
}
markSent(conversationId: string, incomingKey: string, now: number = Date.now()): boolean {
markSent(
conversationId: string,
incomingKey: string,
matrixEventIdOrNow?: string | number | null,
now: number = Date.now(),
): boolean {
const matrixEventId = typeof matrixEventIdOrNow === 'string' ? matrixEventIdOrNow : null;
const sentAt = typeof matrixEventIdOrNow === 'number' ? matrixEventIdOrNow : now;
return this.db.prepare(`
UPDATE conversation_automation_claims
SET status = 'sent', sent_at = ?
SET status = 'sent', sent_at = ?, matrix_event_id = COALESCE(?, matrix_event_id)
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ? AND status = 'pending'
`).run(now, this.accountId, conversationId, incomingKey).changes === 1;
`).run(sentAt, matrixEventId, this.accountId, conversationId, incomingKey).changes === 1;
}
markSuperseded(conversationId: string, incomingKey: string, now: number = Date.now()): boolean {