feat(relationship-worker): ✨ Introduce EventListener and ProcessingPipeline classes for event handling and processing relationship tasks
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c7358a3ef5
commit
56b3d469b1
3 changed files with 672 additions and 0 deletions
111
services/relationship-worker/src/listener.ts
Normal file
111
services/relationship-worker/src/listener.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import type { PoolClient } from 'pg';
|
||||||
|
|
||||||
|
import type { Database } from './db';
|
||||||
|
import { log } from './logger';
|
||||||
|
|
||||||
|
export interface JobEvent {
|
||||||
|
jobId: string;
|
||||||
|
contactId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JobHandler = (event: JobEvent) => Promise<void>;
|
||||||
|
|
||||||
|
const CHANNEL = 'relationship_job_created';
|
||||||
|
|
||||||
|
export class RelationshipJobListener {
|
||||||
|
private client: PoolClient | null = null;
|
||||||
|
private stopped = false;
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly db: Database,
|
||||||
|
private readonly handler: JobHandler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
while (!this.stopped) {
|
||||||
|
try {
|
||||||
|
await this.connectAndListen();
|
||||||
|
} catch (err) {
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.reconnectAttempts += 1;
|
||||||
|
const delayMs = Math.min(1000 * 2 ** this.reconnectAttempts, 30_000);
|
||||||
|
log.error('LISTEN connection failed, reconnecting', {
|
||||||
|
attempt: this.reconnectAttempts,
|
||||||
|
delayMs,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
await sleep(delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.stopped = true;
|
||||||
|
if (this.client) {
|
||||||
|
try {
|
||||||
|
await this.client.query(`UNLISTEN ${CHANNEL}`);
|
||||||
|
} catch {
|
||||||
|
// ignore — connection may already be torn down
|
||||||
|
}
|
||||||
|
this.client.release();
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connectAndListen(): Promise<void> {
|
||||||
|
const client = await this.db.acquireListenClient();
|
||||||
|
this.client = client;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
|
||||||
|
client.on('notification', (msg) => {
|
||||||
|
if (msg.channel !== CHANNEL || !msg.payload) return;
|
||||||
|
this.dispatch(msg.payload).catch((err) => {
|
||||||
|
log.error('Failed to dispatch job notification', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorPromise = new Promise<never>((_, reject) => {
|
||||||
|
client.on('error', (err) => reject(err));
|
||||||
|
client.on('end', () => reject(new Error('LISTEN client ended')));
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.query(`LISTEN ${CHANNEL}`);
|
||||||
|
log.info('Listening for relationship job notifications', { channel: CHANNEL });
|
||||||
|
|
||||||
|
await errorPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatch(rawPayload: string): Promise<void> {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawPayload);
|
||||||
|
} catch {
|
||||||
|
log.warn('Discarding non-JSON notification payload', { rawPayload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = parseEvent(parsed);
|
||||||
|
if (!event) {
|
||||||
|
log.warn('Discarding malformed job notification payload', { rawPayload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Received relationship job notification', { jobId: event.jobId, contactId: event.contactId });
|
||||||
|
await this.handler(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEvent(payload: unknown): JobEvent | null {
|
||||||
|
if (!payload || typeof payload !== 'object') return null;
|
||||||
|
const obj = payload as Record<string, unknown>;
|
||||||
|
if (typeof obj.jobId !== 'string') return null;
|
||||||
|
if (typeof obj.contactId !== 'string') return null;
|
||||||
|
return { jobId: obj.jobId, contactId: obj.contactId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
88
services/relationship-worker/src/main.ts
Normal file
88
services/relationship-worker/src/main.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { ModelBossClient } from '@lilith/messenger-model-boss';
|
||||||
|
|
||||||
|
import { loadConfig } from './config';
|
||||||
|
import { Database } from './db';
|
||||||
|
import { startHealthServer, type HealthState } from './health-server';
|
||||||
|
import { RelationshipJobListener } from './listener';
|
||||||
|
import { log } from './logger';
|
||||||
|
import { RelationshipPipeline } from './pipeline';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const workerId = `relationship-${randomUUID().slice(0, 8)}`;
|
||||||
|
|
||||||
|
log.info('relationship-worker starting', {
|
||||||
|
workerId,
|
||||||
|
modelBossUrl: config.modelBossUrl,
|
||||||
|
model: config.modelBossModel,
|
||||||
|
healthPort: config.healthPort,
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = new Database(config.databaseUrl);
|
||||||
|
const modelBoss = new ModelBossClient(
|
||||||
|
config.modelBossUrl,
|
||||||
|
config.modelBossApiKey,
|
||||||
|
config.modelBossModel,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pipeline = new RelationshipPipeline(db, modelBoss, config.modelBossModel);
|
||||||
|
|
||||||
|
// Cleanup stale jobs from previous runs
|
||||||
|
const cleaned = await db.cleanupStaleJobs();
|
||||||
|
if (cleaned > 0) {
|
||||||
|
log.info('Cleaned up stale relationship jobs', { count: cleaned });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: HealthState = {
|
||||||
|
modelBossReachable: false,
|
||||||
|
lastJobAt: null,
|
||||||
|
lastCompletedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const healthInterval = setInterval(async () => {
|
||||||
|
state.modelBossReachable = await modelBoss.health();
|
||||||
|
}, 30_000);
|
||||||
|
state.modelBossReachable = await modelBoss.health();
|
||||||
|
|
||||||
|
const healthServer = startHealthServer(config.healthPort, () => state);
|
||||||
|
|
||||||
|
const listener = new RelationshipJobListener(db, async (event) => {
|
||||||
|
state.lastJobAt = new Date();
|
||||||
|
try {
|
||||||
|
const processed = await pipeline.handle(event.jobId);
|
||||||
|
if (processed) {
|
||||||
|
state.lastCompletedAt = new Date();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Pipeline failed', {
|
||||||
|
jobId: event.jobId,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
stack: err instanceof Error ? err.stack : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
|
log.info('Shutting down', { signal });
|
||||||
|
clearInterval(healthInterval);
|
||||||
|
healthServer.close();
|
||||||
|
await listener.stop();
|
||||||
|
await db.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||||
|
|
||||||
|
await listener.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
log.error('Fatal error', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
stack: err instanceof Error ? err.stack : undefined,
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
473
services/relationship-worker/src/pipeline.ts
Normal file
473
services/relationship-worker/src/pipeline.ts
Normal file
|
|
@ -0,0 +1,473 @@
|
||||||
|
import { ModelBossClient, ModelBossError } from '@lilith/messenger-model-boss';
|
||||||
|
|
||||||
|
import type { Database, Message, ReviewQueueSignal, TopicSummary } from './db';
|
||||||
|
import { computeEfficiencyMetrics } from './efficiency';
|
||||||
|
import { log } from './logger';
|
||||||
|
|
||||||
|
/** JSON schema for persona synthesis LLM output. */
|
||||||
|
const PERSONA_FRAGMENT_SCHEMA: Record<string, unknown> = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['voice_fragment', 'winning_patterns', 'failure_patterns', 'negative_prompt'],
|
||||||
|
properties: {
|
||||||
|
voice_fragment: { type: 'string' },
|
||||||
|
winning_patterns: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['pattern', 'example', 'outcome'],
|
||||||
|
properties: {
|
||||||
|
pattern: { type: 'string' },
|
||||||
|
example: { type: 'string' },
|
||||||
|
outcome: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
failure_patterns: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['pattern', 'example', 'outcome'],
|
||||||
|
properties: {
|
||||||
|
pattern: { type: 'string' },
|
||||||
|
example: { type: 'string' },
|
||||||
|
outcome: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
negative_prompt: { type: ['string', 'null'] },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** JSON schema for financial backfill LLM output. */
|
||||||
|
const FINANCIAL_EVENTS_SCHEMA: Record<string, unknown> = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['events'],
|
||||||
|
properties: {
|
||||||
|
events: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['booking_date', 'service_type', 'duration', 'amount', 'confidence', 'evidence'],
|
||||||
|
properties: {
|
||||||
|
booking_date: { type: ['string', 'null'] },
|
||||||
|
service_type: { type: ['string', 'null'] },
|
||||||
|
duration: { type: ['string', 'null'] },
|
||||||
|
amount: { type: ['number', 'null'] },
|
||||||
|
confidence: { type: 'number', minimum: 0, maximum: 1 },
|
||||||
|
evidence: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PatternEntry {
|
||||||
|
pattern: string;
|
||||||
|
example: string;
|
||||||
|
outcome: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonaFragmentResult {
|
||||||
|
voice_fragment: string;
|
||||||
|
winning_patterns: PatternEntry[];
|
||||||
|
failure_patterns: PatternEntry[];
|
||||||
|
negative_prompt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FinancialEvent {
|
||||||
|
booking_date: string | null;
|
||||||
|
service_type: string | null;
|
||||||
|
duration: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
confidence: number;
|
||||||
|
evidence: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FinancialEventsResult {
|
||||||
|
events: FinancialEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CONTACTS_PER_EMOJI = 8;
|
||||||
|
const MAX_MESSAGES_PER_CONTACT = 30;
|
||||||
|
const MAX_REVIEW_SIGNALS = 5;
|
||||||
|
const MAX_MESSAGES_FOR_FINANCIAL = 300;
|
||||||
|
|
||||||
|
export class RelationshipPipeline {
|
||||||
|
constructor(
|
||||||
|
private readonly db: Database,
|
||||||
|
private readonly modelBoss: ModelBossClient,
|
||||||
|
private readonly model: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(jobId: string): Promise<boolean> {
|
||||||
|
const job = await this.db.claimJob(jobId);
|
||||||
|
if (!job) {
|
||||||
|
log.debug('Job already claimed or not found', { jobId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Processing relationship job', {
|
||||||
|
jobId: job.id,
|
||||||
|
contactId: job.contactId,
|
||||||
|
kind: job.kind,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (job.kind) {
|
||||||
|
case 'manual':
|
||||||
|
await this.handleManual(job.contactId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'persona_synthesis':
|
||||||
|
await this.handlePersonaSynthesis(job.payload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'financial_backfill':
|
||||||
|
await this.handleFinancialBackfill(job.contactId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'efficiency_scoring':
|
||||||
|
await this.handleEfficiencyScoring(job.contactId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.warn('Unknown job kind', { kind: job.kind, jobId: job.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.completeJob(job.id);
|
||||||
|
log.info('Job completed', { jobId: job.id, kind: job.kind });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await this.db.failJob(job.id, message);
|
||||||
|
log.error('Job failed', {
|
||||||
|
jobId: job.id,
|
||||||
|
kind: job.kind,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Manual refresh — full per-contact recompute
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async handleManual(contactId: string): Promise<void> {
|
||||||
|
await this.handleEfficiencyScoring(contactId);
|
||||||
|
await this.handleFinancialBackfill(contactId);
|
||||||
|
log.info('Manual refresh complete', { contactId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Persona synthesis — derive voice fragment for one emoji
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async handlePersonaSynthesis(payload: Record<string, unknown> | null): Promise<void> {
|
||||||
|
const emoji = payload?.emoji;
|
||||||
|
if (typeof emoji !== 'string') {
|
||||||
|
throw new Error('persona_synthesis requires payload.emoji');
|
||||||
|
}
|
||||||
|
|
||||||
|
const glossary = await this.db.loadEmojiGlossary();
|
||||||
|
const entry = glossary.find((e) => e.emoji === emoji);
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error(`Emoji ${emoji} not found in glossary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = await this.db.loadContactsByEmoji(emoji);
|
||||||
|
if (contacts.length === 0) {
|
||||||
|
log.info('No contacts with this emoji, skipping', { emoji });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampled = contacts.slice(0, MAX_CONTACTS_PER_EMOJI);
|
||||||
|
const digestParts: string[] = [];
|
||||||
|
|
||||||
|
for (const contact of sampled) {
|
||||||
|
const [messages, topics, signals] = await Promise.all([
|
||||||
|
this.db.loadContactMessages(contact.id, MAX_MESSAGES_PER_CONTACT),
|
||||||
|
this.db.loadContactTopics(contact.id),
|
||||||
|
this.db.loadContactReviewSignals(contact.id, 50),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (messages.length === 0) continue;
|
||||||
|
digestParts.push(this.formatContactDigest(contact, messages, topics, signals));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (digestParts.length === 0) {
|
||||||
|
log.info('No message data for any contacts, skipping', { emoji });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = [
|
||||||
|
'You are analyzing Quinn\'s messaging patterns to synthesize a persona fragment.',
|
||||||
|
'',
|
||||||
|
'Quinn is an independent escort and content creator in the Pacific Northwest.',
|
||||||
|
'She labels contacts with emoji to categorize them. You are analyzing all',
|
||||||
|
`conversations with contacts labeled ${emoji} ("${entry.meaning ?? 'no meaning set'}").`,
|
||||||
|
'',
|
||||||
|
'Your job: derive a composable voice description from the evidence below.',
|
||||||
|
'This fragment will be blended with other emoji fragments at draft time —',
|
||||||
|
`keep it focused on what is unique about how Quinn handles ${emoji} contacts.`,
|
||||||
|
'',
|
||||||
|
'Analyze:',
|
||||||
|
'- How does Quinn open conversations with these people?',
|
||||||
|
'- What tone does she use? (casual, flirty, professional, guarded?)',
|
||||||
|
'- What specific phrases or patterns recur in her outgoing messages?',
|
||||||
|
'- When she edited AI drafts, what did she change and why?',
|
||||||
|
'- Which conversations led to bookings? What did she do differently there?',
|
||||||
|
'- Which conversations died? What happened right before?',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const userPrompt = [
|
||||||
|
`## Contacts labeled ${emoji} (${contacts.length} total, ${sampled.length} sampled)`,
|
||||||
|
'',
|
||||||
|
...digestParts,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.modelBoss.chatJson<PersonaFragmentResult>({
|
||||||
|
systemPrompt,
|
||||||
|
messages: [{ role: 'user', content: userPrompt }],
|
||||||
|
model: this.model,
|
||||||
|
schema: PERSONA_FRAGMENT_SCHEMA,
|
||||||
|
schemaName: 'persona_fragment',
|
||||||
|
parse: parsePersonaFragment,
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.db.upsertPersonaFragment({
|
||||||
|
emoji,
|
||||||
|
voiceFragment: result.voice_fragment,
|
||||||
|
winningPatterns: result.winning_patterns,
|
||||||
|
failurePatterns: result.failure_patterns,
|
||||||
|
negativePrompt: result.negative_prompt,
|
||||||
|
sampleCount: sampled.length,
|
||||||
|
modelVersion: this.model,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info('Persona fragment synthesized', {
|
||||||
|
emoji,
|
||||||
|
sampleCount: sampled.length,
|
||||||
|
winningCount: result.winning_patterns.length,
|
||||||
|
failureCount: result.failure_patterns.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ModelBossError) {
|
||||||
|
throw new Error(`model-boss failed for persona synthesis (${emoji}): ${err.message}`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatContactDigest(
|
||||||
|
contact: { id: string; displayName: string },
|
||||||
|
messages: Message[],
|
||||||
|
topics: TopicSummary[],
|
||||||
|
signals: ReviewQueueSignal[],
|
||||||
|
): string {
|
||||||
|
const outgoing = messages.filter((m) => m.direction === 'outgoing');
|
||||||
|
const incoming = messages.filter((m) => m.direction === 'incoming');
|
||||||
|
const avgQuinnLen = outgoing.length > 0
|
||||||
|
? Math.round(outgoing.reduce((s, m) => s + m.text.length, 0) / outgoing.length)
|
||||||
|
: 0;
|
||||||
|
const avgTheirLen = incoming.length > 0
|
||||||
|
? Math.round(incoming.reduce((s, m) => s + m.text.length, 0) / incoming.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`### ${contact.displayName}`,
|
||||||
|
`Messages: ${messages.length} (Quinn: ${outgoing.length}, them: ${incoming.length})`,
|
||||||
|
`Avg message length — Quinn: ${avgQuinnLen} chars, them: ${avgTheirLen} chars`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (topics.length > 0) {
|
||||||
|
const topicSummary = topics
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((t) => `${t.kind ?? 'unknown'}(${t.phase ?? '?'})`)
|
||||||
|
.join(', ');
|
||||||
|
lines.push(`Topics: ${topicSummary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent messages
|
||||||
|
lines.push('', 'Recent messages:');
|
||||||
|
for (const m of messages.slice(-20)) {
|
||||||
|
const who = m.direction === 'outgoing' ? 'Quinn' : 'Them';
|
||||||
|
const date = m.sentAt.toISOString().slice(0, 16).replace('T', ' ');
|
||||||
|
const text = m.text.length > 200 ? m.text.slice(0, 200) + '...' : m.text;
|
||||||
|
lines.push(`[${date}] ${who}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review queue signals
|
||||||
|
const sent = signals.filter((s) => s.status === 'sent');
|
||||||
|
const edited = sent.filter((s) => s.editedText !== null);
|
||||||
|
const unchanged = sent.filter((s) => s.editedText === null);
|
||||||
|
const rejected = signals.filter((s) => s.status === 'rejected');
|
||||||
|
|
||||||
|
if (sent.length > 0 || rejected.length > 0) {
|
||||||
|
lines.push('', 'Review queue signals:');
|
||||||
|
lines.push(`- Approved unchanged: ${unchanged.length}`);
|
||||||
|
lines.push(`- Edited: ${edited.length}`);
|
||||||
|
lines.push(`- Rejected: ${rejected.length}`);
|
||||||
|
|
||||||
|
const editExamples = edited.slice(0, MAX_REVIEW_SIGNALS);
|
||||||
|
for (const e of editExamples) {
|
||||||
|
const draft = e.draftText.length > 100 ? e.draftText.slice(0, 100) + '...' : e.draftText;
|
||||||
|
const edit = (e.editedText ?? '').length > 100
|
||||||
|
? (e.editedText ?? '').slice(0, 100) + '...'
|
||||||
|
: (e.editedText ?? '');
|
||||||
|
lines.push(` Edit: "${draft}" → "${edit}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectExamples = rejected.slice(0, 3);
|
||||||
|
for (const r of rejectExamples) {
|
||||||
|
const draft = r.draftText.length > 100 ? r.draftText.slice(0, 100) + '...' : r.draftText;
|
||||||
|
lines.push(` Rejected: "${draft}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Financial backfill — LLM-driven booking event extraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async handleFinancialBackfill(contactId: string): Promise<void> {
|
||||||
|
const messages = await this.db.loadContactMessages(contactId, MAX_MESSAGES_FOR_FINANCIAL);
|
||||||
|
if (messages.length < 3) {
|
||||||
|
log.debug('Too few messages for financial analysis', { contactId, count: messages.length });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = [
|
||||||
|
'You are analyzing a conversation between Quinn (an independent escort, outgoing',
|
||||||
|
'messages) and a contact (incoming messages). Your job is to identify every instance',
|
||||||
|
'where a booking was confirmed and likely completed.',
|
||||||
|
'',
|
||||||
|
'Look for these signals:',
|
||||||
|
'- Rate or price discussed and agreed to (e.g. "$700/hr", "my rate is...")',
|
||||||
|
'- Date/time confirmed (e.g. "see you saturday", "tomorrow at 7")',
|
||||||
|
'- Deposit mentioned or confirmed (e.g. "deposit received", "sent you $200")',
|
||||||
|
'- Quinn confirming she\'s on her way, arrived, or post-session messages',
|
||||||
|
'- Payment references (Venmo, Cash App, cash, "paid", etc.)',
|
||||||
|
'- Post-session follow-up ("had a great time", rebooking discussion)',
|
||||||
|
'',
|
||||||
|
'For each booking event you identify, extract structured data.',
|
||||||
|
'If no bookings occurred in this conversation, return an empty events array.',
|
||||||
|
'Only include events where you have reasonable confidence a booking actually happened.',
|
||||||
|
'',
|
||||||
|
'Quinn\'s standard rates for reference:',
|
||||||
|
'- 1hr: $700, 2hr: $1400, 3hr: $2100, overnight: $2400, 24hr: $4000',
|
||||||
|
'- Dinner & night: $2800, Duo: $9999',
|
||||||
|
'- Touring: West Coast $3000, NA $5500, International $7000',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const conversationText = messages.map((m) => {
|
||||||
|
const who = m.direction === 'outgoing' ? 'Quinn' : 'Them';
|
||||||
|
const date = m.sentAt.toISOString().slice(0, 16).replace('T', ' ');
|
||||||
|
return `[${date}] ${who}: ${m.text}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.modelBoss.chatJson<FinancialEventsResult>({
|
||||||
|
systemPrompt,
|
||||||
|
messages: [{ role: 'user', content: conversationText }],
|
||||||
|
model: this.model,
|
||||||
|
schema: FINANCIAL_EVENTS_SCHEMA,
|
||||||
|
schemaName: 'financial_events',
|
||||||
|
parse: parseFinancialEvents,
|
||||||
|
timeoutMs: 120_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let written = 0;
|
||||||
|
for (const event of result.events) {
|
||||||
|
if (event.confidence < 0.3) continue;
|
||||||
|
|
||||||
|
await this.db.insertRevenueEvent({
|
||||||
|
contactId,
|
||||||
|
topicId: null,
|
||||||
|
bookingDate: event.booking_date,
|
||||||
|
serviceType: event.service_type,
|
||||||
|
duration: event.duration,
|
||||||
|
amount: event.amount,
|
||||||
|
confidence: event.confidence,
|
||||||
|
evidence: event.evidence,
|
||||||
|
source: 'llm-extracted',
|
||||||
|
});
|
||||||
|
written++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (written > 0) {
|
||||||
|
log.info('Financial events extracted', {
|
||||||
|
contactId,
|
||||||
|
total: result.events.length,
|
||||||
|
written,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ModelBossError) {
|
||||||
|
log.warn('model-boss failed for financial backfill', {
|
||||||
|
contactId,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Efficiency scoring — pure SQL
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async handleEfficiencyScoring(contactId: string): Promise<void> {
|
||||||
|
const metrics = await computeEfficiencyMetrics(this.db, contactId);
|
||||||
|
if (metrics) {
|
||||||
|
await this.db.upsertEfficiencyMetrics(contactId, metrics);
|
||||||
|
log.info('Efficiency metrics computed', {
|
||||||
|
contactId,
|
||||||
|
totalMessages: metrics.totalMessages,
|
||||||
|
bookingCount: metrics.bookingCount,
|
||||||
|
timeToCloseHours: metrics.timeToCloseHours,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.debug('No messages found for efficiency scoring', { contactId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Parsers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function parsePersonaFragment(payload: unknown): PersonaFragmentResult {
|
||||||
|
const obj = payload as Record<string, unknown>;
|
||||||
|
if (typeof obj.voice_fragment !== 'string') {
|
||||||
|
throw new Error('"voice_fragment" must be a string');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(obj.winning_patterns)) {
|
||||||
|
throw new Error('"winning_patterns" must be an array');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(obj.failure_patterns)) {
|
||||||
|
throw new Error('"failure_patterns" must be an array');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
voice_fragment: obj.voice_fragment,
|
||||||
|
winning_patterns: obj.winning_patterns as PatternEntry[],
|
||||||
|
failure_patterns: obj.failure_patterns as PatternEntry[],
|
||||||
|
negative_prompt: typeof obj.negative_prompt === 'string' ? obj.negative_prompt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFinancialEvents(payload: unknown): FinancialEventsResult {
|
||||||
|
const obj = payload as Record<string, unknown>;
|
||||||
|
if (!Array.isArray(obj.events)) {
|
||||||
|
throw new Error('"events" must be an array');
|
||||||
|
}
|
||||||
|
return { events: obj.events as FinancialEvent[] };
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue