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
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user