163 lines
6.0 KiB
TypeScript
163 lines
6.0 KiB
TypeScript
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');
|
|
}
|